Codex Deploy commited on
Commit
191b322
·
0 Parent(s):

Prepare local Hugging Face deployment

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +8 -0
  2. .env.example +8 -0
  3. .gitignore +24 -0
  4. App.tsx +725 -0
  5. Dockerfile +21 -0
  6. README.md +36 -0
  7. components/AttendanceManager.tsx +180 -0
  8. components/Auth.tsx +181 -0
  9. components/BimViewer.tsx +148 -0
  10. components/ChangeOrderManager.tsx +117 -0
  11. components/ClientPortal.tsx +147 -0
  12. components/Collaboration.tsx +204 -0
  13. components/Dashboard.tsx +679 -0
  14. components/DocumentManager.tsx +443 -0
  15. components/EquipmentManager.tsx +111 -0
  16. components/FinancialAnalytics.tsx +143 -0
  17. components/FinancialControl.tsx +768 -0
  18. components/GanttChart.tsx +133 -0
  19. components/Layout.tsx +195 -0
  20. components/LiabilityTracker.tsx +110 -0
  21. components/LocalAssistant.tsx +250 -0
  22. components/ManualOverrideToggle.tsx +26 -0
  23. components/MasterControl.tsx +1064 -0
  24. components/MemberManager.tsx +236 -0
  25. components/PhotoLogs.tsx +156 -0
  26. components/Procurement.tsx +173 -0
  27. components/ProjectList.tsx +256 -0
  28. components/QCSafety.tsx +182 -0
  29. components/Reporting.tsx +115 -0
  30. components/RiskAssessment.tsx +117 -0
  31. components/SiteExecution.tsx +1015 -0
  32. components/SubcontractorPortal.tsx +138 -0
  33. components/SustainabilityTracker.tsx +159 -0
  34. components/TaskManager.tsx +361 -0
  35. components/VendorAnalytics.tsx +117 -0
  36. components/WeatherWidget.tsx +99 -0
  37. constants.ts +271 -0
  38. contexts/NotificationContext.tsx +46 -0
  39. hooks/useLocalCollection.ts +85 -0
  40. index.css +89 -0
  41. index.html +34 -0
  42. index.tsx +15 -0
  43. metadata.json +5 -0
  44. migrated_prompt_history/prompt_2025-11-26T21_18_26.606Z.json +0 -0
  45. migrated_prompt_history/prompt_2025-11-26T22_34_44.634Z.json +0 -0
  46. migrated_prompt_history/prompt_2026-01-21T18_05_04.673Z.json +0 -0
  47. migrated_prompt_history/prompt_2026-01-21T23_54_40.133Z.json +0 -0
  48. package-lock.json +0 -0
  49. package.json +43 -0
  50. server.ts +237 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .git
4
+ .env
5
+ .env.local
6
+ *.log
7
+ output
8
+ migrated_prompt_history
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Optional. Leave empty to use the in-memory local database.
2
+ # MONGODB_URI=mongodb://localhost:27017/buildtrack_db
3
+
4
+ # Optional. Set a strong value for production login tokens.
5
+ # JWT_SECRET=replace-with-a-long-random-secret
6
+
7
+ # Hugging Face Spaces sets PORT automatically. Local default is 7860.
8
+ # PORT=7860
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
App.tsx ADDED
@@ -0,0 +1,725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { NotificationProvider } from './contexts/NotificationContext';
4
+ import Layout from './components/Layout';
5
+ import Dashboard from './components/Dashboard';
6
+ import MasterControl from './components/MasterControl';
7
+ import SiteExecution from './components/SiteExecution';
8
+ import FinancialControl from './components/FinancialControl';
9
+ import LiabilityTracker from './components/LiabilityTracker';
10
+ import DocumentManager from './components/DocumentManager';
11
+ import ProjectList from './components/ProjectList';
12
+ import Auth from './components/Auth';
13
+ import TaskManager from './components/TaskManager';
14
+ import MemberManager from './components/MemberManager';
15
+ import GanttChart from './components/GanttChart';
16
+ import FinancialAnalytics from './components/FinancialAnalytics';
17
+ import Procurement from './components/Procurement';
18
+ import SubcontractorPortal from './components/SubcontractorPortal';
19
+ import QCSafety from './components/QCSafety';
20
+ import Reporting from './components/Reporting';
21
+ import PhotoLogs from './components/PhotoLogs';
22
+ import EquipmentManager from './components/EquipmentManager';
23
+ import AttendanceManager from './components/AttendanceManager';
24
+ import ChangeOrderManager from './components/ChangeOrderManager';
25
+ import SustainabilityTracker from './components/SustainabilityTracker';
26
+ import BimViewer from './components/BimViewer';
27
+ import ClientPortal from './components/ClientPortal';
28
+ import VendorAnalytics from './components/VendorAnalytics';
29
+ import LocalAssistant from './components/LocalAssistant';
30
+ import { CommentSection } from './components/Collaboration';
31
+ import { MOCK_PROJECTS } from './constants';
32
+ import { ProjectState, ProjectDocument, DPR, UserRole, BOQItem, AiSuggestion, Material, Bill, ExtractedDPR, User, Task } from './types';
33
+ import { parseBOQDocument, analyzeDocumentContent, processWhatsAppMessage } from './services/localAnalysisService';
34
+ import { MessageSquare, Send, Loader2, Smartphone, AlertCircle, LayoutDashboard, PlusCircle } from 'lucide-react';
35
+ import { useLocalCollection } from './hooks/useLocalCollection';
36
+
37
+ const stripUndefined = (obj: any): any => {
38
+ if (Array.isArray(obj)) return obj.map(stripUndefined);
39
+ if (typeof obj === 'object' && obj !== null) {
40
+ const res: any = {};
41
+ for (const key in obj) {
42
+ if (obj[key] !== undefined) res[key] = stripUndefined(obj[key]);
43
+ }
44
+ return res;
45
+ }
46
+ return obj;
47
+ };
48
+
49
+ const App: React.FC = () => {
50
+ const [user, setUser] = useState<User | null>(null);
51
+ const [isAuthReady, setIsAuthReady] = useState(false);
52
+ const [activeTab, setActiveTab] = useState('dashboard');
53
+
54
+ const authRefreshKey = user?.uid || localStorage.getItem('auth_token') || 'guest';
55
+ const { data: projectsData, add: addProject, update: updateProjectStorage } = useLocalCollection<ProjectState>('projects', authRefreshKey);
56
+
57
+ const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
58
+ const [activeProjectRole, setActiveProjectRole] = useState<UserRole | null>(null);
59
+ const [activeProjectTasks, setActiveProjectTasks] = useState<Task[]>([]);
60
+ const [activeProjectMembers, setActiveProjectMembers] = useState<User[]>([]);
61
+ const [isSimulatingWhatsApp, setIsSimulatingWhatsApp] = useState(false);
62
+ const [whatsappMessage, setWhatsappMessage] = useState('');
63
+ const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
64
+
65
+ // Connection Test omitted since Firebase is removed
66
+ // Auth Listener replaced with short initialization
67
+ useEffect(() => {
68
+ setIsAuthReady(true);
69
+ }, []);
70
+
71
+ // Sync Projects
72
+ const projects = projectsData;
73
+ useEffect(() => {
74
+ if (projects.length === 0 && user) {
75
+ MOCK_PROJECTS.forEach(p => {
76
+ addProject({ ...p, ownerUid: user.uid } as any);
77
+ });
78
+ }
79
+ }, [user, projects.length, addProject]);
80
+
81
+ // Project Role Listener
82
+ useEffect(() => {
83
+ if (user && activeProjectId) {
84
+ // Local mode: give them the role they logged in with for everything
85
+ setActiveProjectRole(user.role);
86
+ }
87
+ }, [user, activeProjectId]);
88
+
89
+ useEffect(() => {
90
+ if (user) {
91
+ setActiveProjectMembers([user]);
92
+ }
93
+ }, [user]);
94
+
95
+ useEffect(() => {
96
+ if (!activeProjectId && projects.length > 0) {
97
+ setActiveProjectId(projects[0].id);
98
+ }
99
+ }, [activeProjectId, projects]);
100
+
101
+ // Tasks local fetch
102
+ useEffect(() => {
103
+ const fetchTasks = async () => {
104
+ try {
105
+ const token = localStorage.getItem('auth_token');
106
+ const res = await fetch(`/api/collections/tasks_${activeProjectId}`, {
107
+ headers: token ? { 'Authorization': `Bearer ${token}` } : {}
108
+ });
109
+ if (!res.ok) throw new Error("Tasks fetch failed");
110
+ const data = await res.json();
111
+ setActiveProjectTasks(data || []);
112
+ } catch (e) {
113
+ console.error("Failed to fetch tasks", e);
114
+ }
115
+ };
116
+ if (activeProjectId) {
117
+ fetchTasks();
118
+ }
119
+ }, [activeProjectId]);
120
+
121
+ const activeProject = projects.find(p => p.id === activeProjectId);
122
+
123
+ const handleLogout = async () => {
124
+ localStorage.removeItem('local_user_uid');
125
+ localStorage.removeItem('auth_token');
126
+ setUser(null);
127
+ setActiveProjectId(null);
128
+ };
129
+
130
+ const handleCreateProject = async (newProject: Partial<ProjectState>) => {
131
+ if (!user) return;
132
+ const id = `P${Date.now()}`;
133
+ const project: ProjectState = {
134
+ ...newProject as ProjectState,
135
+ id,
136
+ ownerUid: user.uid,
137
+ memberUids: [user.uid],
138
+ aiSuggestions: [],
139
+ materials: [],
140
+ subContractors: [],
141
+ documents: [],
142
+ dprs: [],
143
+ boq: [],
144
+ bills: [],
145
+ liabilities: [],
146
+ milestones: [],
147
+ purchaseOrders: [],
148
+ qualityChecks: [],
149
+ safetyChecks: [],
150
+ photoLogs: [],
151
+ equipment: [],
152
+ attendance: [],
153
+ changeOrders: [],
154
+ vendors: [],
155
+ weatherForecast: [],
156
+ bimModels: []
157
+ };
158
+
159
+ addProject(stripUndefined(project));
160
+ setActiveProjectId(id);
161
+ };
162
+
163
+ const handleUpdateProject = async (projectId: string, updater: (proj: ProjectState) => ProjectState) => {
164
+ const project = projects.find(p => p.id === projectId);
165
+ if (!project) return;
166
+
167
+ const updated = updater(project);
168
+ updateProjectStorage(projectId, stripUndefined(updated));
169
+ };
170
+
171
+ const handleAddDocument = async (newDoc: ProjectDocument) => {
172
+ if (!activeProjectId || !activeProject) return;
173
+
174
+ // 1. Add Document immediately
175
+ handleUpdateProject(activeProjectId, (project) => ({
176
+ ...project,
177
+ documents: [newDoc, ...project.documents]
178
+ }));
179
+
180
+ // 2. Trigger Auto-Analysis based on Doc Type
181
+ try {
182
+ let mimeType = 'application/pdf';
183
+ if (newDoc.type === 'JPG' || newDoc.type === 'PNG') mimeType = 'image/jpeg';
184
+
185
+ const suggestions = await analyzeDocumentContent(newDoc.name, newDoc.category, activeProject.boq, newDoc.content, mimeType);
186
+
187
+ if (suggestions && suggestions.length > 0) {
188
+ handleUpdateProject(activeProjectId, (project) => ({
189
+ ...project,
190
+ documents: project.documents.map(d => d.id === newDoc.id ? { ...d, isAnalyzed: true } : d),
191
+ aiSuggestions: [...suggestions.map(s => ({ ...s, docId: newDoc.id })), ...project.aiSuggestions]
192
+ }));
193
+ }
194
+ } catch (e) {
195
+ console.error("Auto-analysis failed", e);
196
+ }
197
+ };
198
+
199
+ const handleAnalyzeDocument = (docId: string, suggestions: AiSuggestion[]) => {
200
+ if (!activeProjectId) return;
201
+ handleUpdateProject(activeProjectId, (project) => ({
202
+ ...project,
203
+ documents: project.documents.map(d => d.id === docId ? { ...d, isAnalyzed: true } : d),
204
+ aiSuggestions: [...suggestions, ...project.aiSuggestions]
205
+ }));
206
+ setActiveTab('dashboard'); // Switch to dashboard to see results
207
+ };
208
+
209
+ const handleImportBOQItems = (items: BOQItem[]) => {
210
+ if (!activeProjectId) return;
211
+ handleUpdateProject(activeProjectId, (project) => ({
212
+ ...project,
213
+ boq: [...project.boq, ...items] // Append new items. In real app, this might merge or replace.
214
+ }));
215
+ };
216
+
217
+ const handleApplySuggestion = async (suggestionId: string) => {
218
+ if (!activeProjectId || !activeProject) return;
219
+ const suggestion = activeProject.aiSuggestions.find(s => s.id === suggestionId);
220
+ if (!suggestion) return;
221
+
222
+ if (suggestion.type === 'BOQ_IMPORT') {
223
+ const relatedDoc = activeProject.documents.find(d => d.id === suggestion.docId);
224
+ if (relatedDoc) {
225
+ let mimeType = 'application/pdf';
226
+ if (relatedDoc.type === 'JPG' || relatedDoc.type === 'PNG') mimeType = 'image/jpeg';
227
+ const items = await parseBOQDocument(relatedDoc.name, relatedDoc.content, mimeType);
228
+ handleImportBOQItems(items);
229
+ }
230
+ handleUpdateProject(activeProjectId, (project) => ({
231
+ ...project,
232
+ aiSuggestions: project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'APPLIED' as const } : s)
233
+ }));
234
+ return;
235
+ }
236
+
237
+ if (suggestion.type === 'DPR_ENTRY' && suggestion.value) {
238
+ const dprData = suggestion.value as ExtractedDPR;
239
+ // Resolve IDs
240
+ let subId = undefined;
241
+ if (dprData.subContractorName) {
242
+ subId = activeProject.subContractors?.find(s =>
243
+ s.name.toLowerCase().includes(dprData.subContractorName!.toLowerCase())
244
+ )?.id;
245
+ }
246
+
247
+ let materialsUsed = [];
248
+ if (dprData.materials) {
249
+ materialsUsed = dprData.materials.map(m => {
250
+ const mat = activeProject.materials.find(ex => ex.name.toLowerCase().includes(m.name.toLowerCase()));
251
+ return mat ? { materialId: mat.id, qty: m.qty } : null;
252
+ }).filter(Boolean) as any;
253
+ }
254
+
255
+ const newDPR: DPR = {
256
+ id: `DPR-AI-${Date.now()}`,
257
+ date: dprData.date || new Date().toISOString().split('T')[0],
258
+ activity: dprData.activity || 'Reported Activity',
259
+ location: dprData.location || 'Site',
260
+ laborCount: dprData.laborCount || 0,
261
+ remarks: dprData.remarks || '',
262
+ linkedBoqId: dprData.linkedBoqId,
263
+ workDoneQty: dprData.workDoneQty,
264
+ subContractorId: subId,
265
+ materialsUsed: materialsUsed
266
+ };
267
+ handleAddDPR(newDPR);
268
+
269
+ handleUpdateProject(activeProjectId, (project) => ({
270
+ ...project,
271
+ aiSuggestions: project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'APPLIED' as const } : s)
272
+ }));
273
+ return;
274
+ }
275
+
276
+ handleUpdateProject(activeProjectId, (project) => {
277
+ let updatedProject = { ...project };
278
+
279
+ // Update data based on suggestion type
280
+ if (suggestion.type === 'QUANTITY_UPDATE' && suggestion.linkedId && suggestion.value) {
281
+ updatedProject.boq = project.boq.map(b => b.id === suggestion.linkedId ? { ...b, executedQty: b.executedQty + suggestion.value } : b);
282
+ } else if (suggestion.type === 'BILL_DETECTION' && suggestion.value) {
283
+ const billVal = suggestion.value as any; // could be object or number
284
+ const amount = typeof billVal === 'object' ? billVal.amount : billVal;
285
+
286
+ const newBill = {
287
+ id: `BILL-AI-${Date.now()}`,
288
+ type: 'VENDOR_INVOICE' as const,
289
+ entityName: suggestion.title.split('from ')[1] || 'Unknown Vendor',
290
+ amount: Number(amount),
291
+ date: new Date().toISOString().split('T')[0],
292
+ status: 'PENDING' as const
293
+ };
294
+ updatedProject.bills = [newBill, ...project.bills];
295
+ }
296
+
297
+ updatedProject.aiSuggestions = project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'APPLIED' as const } : s);
298
+ return updatedProject;
299
+ });
300
+ };
301
+
302
+ const handleDismissSuggestion = (suggestionId: string) => {
303
+ if (!activeProjectId) return;
304
+ handleUpdateProject(activeProjectId, (project) => ({
305
+ ...project,
306
+ aiSuggestions: project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'DISMISSED' as const } : s)
307
+ }));
308
+ };
309
+
310
+ const handleAddDPR = (newDPR: DPR) => {
311
+ if (!activeProjectId) return;
312
+ handleUpdateProject(activeProjectId, (project) => {
313
+ const updatedDPRs = [newDPR, ...project.dprs];
314
+ let updatedBOQ = project.boq;
315
+ let updatedSubContractors = project.subContractors;
316
+ let updatedLiabilities = project.liabilities;
317
+
318
+ // 1. Update BOQ Executed Qty
319
+ if (newDPR.linkedBoqId && newDPR.workDoneQty) {
320
+ updatedBOQ = project.boq.map(item => {
321
+ if (item.id === newDPR.linkedBoqId) {
322
+ return { ...item, executedQty: item.executedQty + (newDPR.workDoneQty || 0) };
323
+ }
324
+ return item;
325
+ });
326
+
327
+ // 2. Automated Sub-contractor Progress Tracking
328
+ if (newDPR.subContractorId && newDPR.workDoneQty) {
329
+ const sub = project.subContractors.find(s => s.id === newDPR.subContractorId);
330
+ if (sub) {
331
+ // Find agreed rate for this BOQ item
332
+ const rateInfo = sub.agreedRates.find(r => r.boqId === newDPR.linkedBoqId);
333
+ const rate = rateInfo ? rateInfo.rate : 0;
334
+ const workValue = newDPR.workDoneQty * rate;
335
+
336
+ if (workValue > 0) {
337
+ // Update SC stats
338
+ updatedSubContractors = project.subContractors.map(s => {
339
+ if (s.id === sub.id) {
340
+ return {
341
+ ...s,
342
+ totalWorkValue: s.totalWorkValue + workValue,
343
+ currentLiability: s.currentLiability + workValue
344
+ };
345
+ }
346
+ return s;
347
+ });
348
+
349
+ // Create Liability Entry automatically
350
+ const newLiability = {
351
+ id: `L-AUTO-${Date.now()}`,
352
+ description: `Unbilled Work: ${sub.name} (${newDPR.date})`,
353
+ type: 'UNBILLED_WORK' as const,
354
+ amount: workValue,
355
+ dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] // Net 30 default
356
+ };
357
+ updatedLiabilities = [newLiability, ...project.liabilities];
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ // 3. Update Material Stock
364
+ let updatedMaterials = project.materials;
365
+ if (newDPR.materialsUsed && newDPR.materialsUsed.length > 0) {
366
+ updatedMaterials = project.materials.map(mat => {
367
+ const used = newDPR.materialsUsed?.find(u => u.materialId === mat.id);
368
+ if (used) {
369
+ return {
370
+ ...mat,
371
+ totalConsumed: mat.totalConsumed + used.qty,
372
+ currentStock: mat.currentStock - used.qty
373
+ };
374
+ }
375
+ return mat;
376
+ });
377
+ }
378
+
379
+ return {
380
+ ...project,
381
+ dprs: updatedDPRs,
382
+ boq: updatedBOQ,
383
+ materials: updatedMaterials,
384
+ subContractors: updatedSubContractors,
385
+ liabilities: updatedLiabilities
386
+ };
387
+ });
388
+ };
389
+
390
+ const handleReceiveMaterial = (materialId: string, receivedQty: number, newRate?: number) => {
391
+ if (!activeProjectId) return;
392
+ handleUpdateProject(activeProjectId, (project) => ({
393
+ ...project,
394
+ materials: project.materials.map(mat => {
395
+ if (mat.id === materialId) {
396
+ const newTotalReceived = mat.totalReceived + receivedQty;
397
+ const newStock = mat.currentStock + receivedQty;
398
+ // Weighted Average Rate Calculation
399
+ const oldVal = mat.currentStock * mat.averageRate;
400
+ const newVal = receivedQty * (newRate || mat.averageRate);
401
+ const newAvgRate = (oldVal + newVal) / newStock;
402
+
403
+ return {
404
+ ...mat,
405
+ totalReceived: newTotalReceived,
406
+ currentStock: newStock,
407
+ averageRate: newRate ? newAvgRate : mat.averageRate
408
+ };
409
+ }
410
+ return mat;
411
+ })
412
+ }));
413
+ };
414
+
415
+ const handleAddBill = (newBill: Bill) => {
416
+ if (!activeProjectId) return;
417
+ handleUpdateProject(activeProjectId, (project) => ({
418
+ ...project,
419
+ bills: [newBill, ...project.bills]
420
+ }));
421
+ };
422
+
423
+ const handleBillItemizedUpdate = (items: { boqId: string; amount: number }[]) => {
424
+ if (!activeProjectId) return;
425
+ handleUpdateProject(activeProjectId, (project) => ({
426
+ ...project,
427
+ boq: project.boq.map(b => {
428
+ const update = items.find(i => i.boqId === b.id);
429
+ if (update) {
430
+ return { ...b, billedAmount: (b.billedAmount || 0) + update.amount };
431
+ }
432
+ return b;
433
+ })
434
+ }));
435
+ };
436
+
437
+ const handleUpdatePDRemarks = (entityType: 'MATERIAL' | 'BILL' | 'DPR' | 'SUBCONTRACTOR', entityId: string, remarks: string) => {
438
+ if (!activeProjectId) return;
439
+ handleUpdateProject(activeProjectId, (project) => {
440
+ if (entityType === 'MATERIAL') {
441
+ return { ...project, materials: project.materials.map(m => m.id === entityId ? { ...m, pdRemarks: remarks } : m) };
442
+ }
443
+ if (entityType === 'BILL') {
444
+ return { ...project, bills: project.bills.map(b => b.id === entityId ? { ...b, pdRemarks: remarks } : b) };
445
+ }
446
+ if (entityType === 'SUBCONTRACTOR') {
447
+ return { ...project, subContractors: project.subContractors.map(s => s.id === entityId ? { ...s, pdRemarks: remarks } : s) };
448
+ }
449
+ return project;
450
+ });
451
+ };
452
+
453
+ const handleAddBOQItem = (newItem: BOQItem) => {
454
+ if (!activeProjectId) return;
455
+ handleUpdateProject(activeProjectId, (project) => ({
456
+ ...project,
457
+ boq: [...project.boq, newItem]
458
+ }));
459
+ };
460
+
461
+ const handleUpdateBOQItem = (itemId: string, updatedItem: Partial<BOQItem>) => {
462
+ if (!activeProjectId) return;
463
+ handleUpdateProject(activeProjectId, (project) => ({
464
+ ...project,
465
+ boq: project.boq.map(item => item.id === itemId ? { ...item, ...updatedItem } : item)
466
+ }));
467
+ };
468
+
469
+ const handleSimulateWhatsApp = async () => {
470
+ if (!activeProjectId || !activeProject || !whatsappMessage.trim()) return;
471
+
472
+ setIsSimulatingWhatsApp(true);
473
+ try {
474
+ const extracted = await processWhatsAppMessage(whatsappMessage, activeProject.boq);
475
+ if (extracted) {
476
+ // Create an AI Suggestion based on WhatsApp message
477
+ const newSuggestion: AiSuggestion = {
478
+ id: `WA-SUG-${Date.now()}`,
479
+ docId: 'WHATSAPP',
480
+ type: 'DPR_ENTRY',
481
+ title: 'WhatsApp Progress Update',
482
+ description: `Extracted from message: "${whatsappMessage.substring(0, 50)}..."`,
483
+ value: extracted,
484
+ status: 'PENDING'
485
+ };
486
+
487
+ handleUpdateProject(activeProjectId, (project) => ({
488
+ ...project,
489
+ aiSuggestions: [newSuggestion, ...project.aiSuggestions]
490
+ }));
491
+
492
+ setWhatsappMessage('');
493
+ setActiveTab('dashboard');
494
+ }
495
+ } catch (err) {
496
+ console.error("WhatsApp simulation failed", err);
497
+ } finally {
498
+ setIsSimulatingWhatsApp(false);
499
+ }
500
+ };
501
+
502
+ if (!isAuthReady) {
503
+ return (
504
+ <div className="min-h-screen flex flex-col items-center justify-center bg-slate-50">
505
+ <Loader2 className="w-10 h-10 text-blue-600 animate-spin mb-4" />
506
+ </div>
507
+ );
508
+ }
509
+
510
+ if (!user) {
511
+ return <Auth onUserChange={setUser} />;
512
+ }
513
+
514
+ if (!activeProject) {
515
+ return (
516
+ <div className="min-h-screen bg-slate-50">
517
+ <ProjectList
518
+ projects={projects}
519
+ onSelectProject={setActiveProjectId}
520
+ onCreateProject={handleCreateProject}
521
+ userRole={user.role}
522
+ onSwitchRole={() => {}} // Disabled for real users
523
+ />
524
+ </div>
525
+ );
526
+ }
527
+
528
+ const renderContent = () => {
529
+ if (!activeProjectId || !activeProject) {
530
+ return (
531
+ <div className="flex flex-col items-center justify-center h-[calc(100vh-12rem)] text-slate-400">
532
+ <div className="p-8 rounded-full mb-6 bg-slate-100">
533
+ <LayoutDashboard className="w-16 h-16 opacity-20" />
534
+ </div>
535
+ <h2 className="text-2xl font-bold text-slate-600 mb-2">No Project Selected</h2>
536
+ <p className="max-w-md text-center mb-8">
537
+ Please select a project from the sidebar to view its dashboard and manage construction activities.
538
+ </p>
539
+ <button
540
+ onClick={() => setActiveProjectId(null)}
541
+ className="flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-xl hover:bg-blue-700 transition-all shadow-lg active:scale-95 font-semibold"
542
+ >
543
+ <PlusCircle className="w-5 h-5" />
544
+ Go to Project List
545
+ </button>
546
+ </div>
547
+ );
548
+ }
549
+
550
+ switch (activeTab) {
551
+ case 'dashboard':
552
+ return (
553
+ <Dashboard
554
+ data={activeProject}
555
+ onApplySuggestion={handleApplySuggestion}
556
+ onDismissSuggestion={handleDismissSuggestion}
557
+ onUpdateProject={(updater) => handleUpdateProject(activeProjectId, updater)}
558
+ />
559
+ );
560
+ case 'master':
561
+ return <MasterControl
562
+ data={activeProject}
563
+ onAddDocument={handleAddDocument}
564
+ onAddBOQItem={handleAddBOQItem}
565
+ onUpdateBOQItem={handleUpdateBOQItem}
566
+ onImportBOQItems={handleImportBOQItems}
567
+ userRole={activeProjectRole || user.role}
568
+ />;
569
+ case 'site':
570
+ return <SiteExecution
571
+ data={activeProject}
572
+ onAddDocument={handleAddDocument}
573
+ onAddDPR={handleAddDPR}
574
+ onReceiveMaterial={handleReceiveMaterial}
575
+ onUpdatePDRemarks={handleUpdatePDRemarks}
576
+ userRole={activeProjectRole || user.role}
577
+ />;
578
+ case 'finance':
579
+ return <FinancialControl
580
+ data={activeProject}
581
+ onAddDocument={handleAddDocument}
582
+ onUpdateBOQItem={handleUpdateBOQItem}
583
+ onAddBill={handleAddBill}
584
+ onUpdatePDRemarks={handleUpdatePDRemarks}
585
+ onBillItemizedUpdate={handleBillItemizedUpdate}
586
+ userRole={activeProjectRole || user.role}
587
+ />;
588
+ case 'analytics':
589
+ return (
590
+ <div className="space-y-6">
591
+ <FinancialAnalytics boq={activeProject.boq} bills={activeProject.bills} />
592
+ <div className="pt-6 border-t border-slate-200">
593
+ <h3 className="text-lg font-bold text-slate-800 mb-4">Sustainability & Waste Tracking</h3>
594
+ <SustainabilityTracker metrics={activeProject.sustainabilityMetrics || { carbonFootprint: 0, waterUsage: 0, wasteGenerated: [] }} />
595
+ </div>
596
+ </div>
597
+ );
598
+ case 'procurement':
599
+ return (
600
+ <div className="space-y-6">
601
+ <Procurement materials={activeProject.materials} purchaseOrders={activeProject.purchaseOrders || []} />
602
+ <div className="pt-6 border-t border-slate-200">
603
+ <h3 className="text-lg font-bold text-slate-800 mb-4">Vendor Performance</h3>
604
+ <VendorAnalytics vendors={activeProject.vendors || []} />
605
+ </div>
606
+ </div>
607
+ );
608
+ case 'equipment':
609
+ return <EquipmentManager equipment={activeProject.equipment || []} />;
610
+ case 'labor':
611
+ return <AttendanceManager attendance={activeProject.attendance || []} />;
612
+ case 'subcontractors':
613
+ return <SubcontractorPortal subContractors={activeProject.subContractors} dprs={activeProject.dprs} />;
614
+ case 'qc-safety':
615
+ return <QCSafety qualityChecks={activeProject.qualityChecks || []} safetyChecks={activeProject.safetyChecks || []} users={activeProjectMembers} />;
616
+ case 'gantt':
617
+ return <GanttChart tasks={activeProjectTasks} />;
618
+ case 'bim':
619
+ return <BimViewer models={activeProject.bimModels || []} />;
620
+ case 'photos':
621
+ return <PhotoLogs photoLogs={activeProject.photoLogs || []} users={activeProjectMembers} />;
622
+ case 'reports':
623
+ return <Reporting project={activeProject} />;
624
+ case 'client':
625
+ return <ClientPortal project={activeProject} />;
626
+ case 'liability':
627
+ return <LiabilityTracker data={activeProject} onAddDocument={handleAddDocument} userRole={activeProjectRole || user.role} />;
628
+ case 'documents':
629
+ return (
630
+ <div className="h-[calc(100vh-8rem)] flex gap-6">
631
+ <div className="flex-1">
632
+ <DocumentManager
633
+ documents={activeProject.documents}
634
+ onAddDocument={handleAddDocument}
635
+ onAnalyzeDocument={handleAnalyzeDocument}
636
+ onSelectDocument={setSelectedDocId}
637
+ boqItems={activeProject.boq}
638
+ allowUpload={(activeProjectRole || user.role) === 'DIRECTOR' || (activeProjectRole || user.role) === 'MANAGER' || (activeProjectRole || user.role) === 'ENGINEER'}
639
+ />
640
+ </div>
641
+ {selectedDocId && (
642
+ <div className="w-80 h-full">
643
+ <CommentSection
644
+ projectId={activeProject.id}
645
+ targetId={selectedDocId}
646
+ targetType="DOCUMENT"
647
+ currentUser={user}
648
+ />
649
+ </div>
650
+ )}
651
+ </div>
652
+ );
653
+ case 'tasks':
654
+ return (
655
+ <div className="h-[calc(100vh-8rem)]">
656
+ <TaskManager
657
+ projectId={activeProject.id}
658
+ currentUser={user}
659
+ />
660
+ </div>
661
+ );
662
+ case 'team':
663
+ return (
664
+ <div className="h-[calc(100vh-8rem)]">
665
+ <MemberManager
666
+ projectId={activeProject.id}
667
+ ownerUid={activeProject.ownerUid}
668
+ currentUserUid={user.uid || ''}
669
+ />
670
+ </div>
671
+ );
672
+ default:
673
+ return <Dashboard data={activeProject} onApplySuggestion={handleApplySuggestion} onDismissSuggestion={handleDismissSuggestion} onUpdateProject={(updater) => handleUpdateProject(activeProjectId, updater)} />;
674
+ }
675
+ };
676
+
677
+ return (
678
+ <NotificationProvider>
679
+ <Layout
680
+ activeTab={activeTab}
681
+ setActiveTab={setActiveTab}
682
+ onSwitchProject={() => setActiveProjectId(null)}
683
+ projectName={activeProject.name}
684
+ user={{ ...user, role: activeProjectRole || user.role }}
685
+ onLogout={handleLogout}
686
+ >
687
+ <div className="flex flex-col lg:flex-row gap-6">
688
+ <div className="flex-1">
689
+ {renderContent()}
690
+ </div>
691
+
692
+ {/* Collaboration Sidebar */}
693
+ <div className="w-full lg:w-80 flex flex-col gap-6">
694
+ <LocalAssistant currentUser={user!} projectContext={activeProject} />
695
+ <div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
696
+ <div className="flex items-center gap-2 mb-4">
697
+ <Smartphone className="w-4 h-4 text-emerald-600" />
698
+ <h4 className="font-bold text-slate-800 text-sm">WhatsApp DPR Simulation</h4>
699
+ </div>
700
+ <p className="text-[10px] text-slate-500 mb-3 leading-relaxed">
701
+ Paste a message from your site WhatsApp group to automatically extract progress data.
702
+ </p>
703
+ <textarea
704
+ placeholder="e.g. Today we completed 50sqm of brickwork at block A. 5 masons were present."
705
+ value={whatsappMessage}
706
+ onChange={(e) => setWhatsappMessage(e.target.value)}
707
+ className="w-full p-3 text-xs border border-slate-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none h-24 resize-none mb-3"
708
+ />
709
+ <button
710
+ onClick={handleSimulateWhatsApp}
711
+ disabled={isSimulatingWhatsApp || !whatsappMessage.trim()}
712
+ className="w-full flex items-center justify-center gap-2 bg-emerald-600 text-white py-2 rounded-lg text-xs font-bold hover:bg-emerald-700 transition-all disabled:opacity-50"
713
+ >
714
+ {isSimulatingWhatsApp ? <Loader2 className="w-3 h-3 animate-spin" /> : <Send className="w-3 h-3" />}
715
+ Process Message
716
+ </button>
717
+ </div>
718
+ </div>
719
+ </div>
720
+ </Layout>
721
+ </NotificationProvider>
722
+ );
723
+ };
724
+
725
+ export default App;
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS build
2
+
3
+ WORKDIR /app
4
+ COPY package*.json ./
5
+ RUN npm ci
6
+ COPY . .
7
+ RUN npm run build
8
+
9
+ FROM node:20-slim AS runtime
10
+
11
+ WORKDIR /app
12
+ ENV NODE_ENV=production
13
+ ENV PORT=7860
14
+
15
+ COPY package*.json ./
16
+ RUN npm ci --omit=dev
17
+ COPY --from=build /app/dist ./dist
18
+ COPY server.ts ./server.ts
19
+
20
+ EXPOSE 7860
21
+ CMD ["npm", "start"]
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: BuildTrack Local
3
+ emoji: 🏗️
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # BuildTrack Local
12
+
13
+ A fully local construction project management system. The assistant, document suggestions, BOQ parsing, risk scoring, and project insights run with deterministic local logic inside the app. No Gemini, OpenAI, Anthropic, Hugging Face Inference API, or other hosted AI key is required.
14
+
15
+ ## Run Locally
16
+
17
+ **Prerequisites:** Node.js 20+
18
+
19
+ 1. Install dependencies:
20
+ `npm install`
21
+ 2. Optional: copy [.env.example](.env.example) to `.env.local` and set `MONGODB_URI` or `JWT_SECRET`.
22
+ 3. Start the app:
23
+ `npm run dev`
24
+ 4. Open `http://localhost:7860` unless you set a different `PORT`.
25
+
26
+ If MongoDB is not available, the server automatically uses an in-memory local database for the current session.
27
+
28
+ ## Deploy To Hugging Face Spaces
29
+
30
+ Create a new Hugging Face Space using the **Docker** SDK, then upload this repository. The included [Dockerfile](Dockerfile) builds the Vite frontend and starts the local Express server on the Space port.
31
+
32
+ No AI environment variables are needed. Optional variables:
33
+
34
+ - `MONGODB_URI` - connect to your own MongoDB instance. Leave blank for in-memory storage.
35
+ - `JWT_SECRET` - custom signing secret for login tokens.
36
+ - `PORT` - Hugging Face sets this automatically.
components/AttendanceManager.tsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState } from 'react';
3
+ import { AttendanceRecord } from '../types';
4
+ import { UserCheck, Search, Filter, Calendar, Clock, MapPin, MoreVertical, Loader2 } from 'lucide-react';
5
+
6
+ interface AttendanceManagerProps {
7
+ attendance: AttendanceRecord[];
8
+ }
9
+
10
+ const AttendanceManager: React.FC<AttendanceManagerProps> = ({ attendance }) => {
11
+ const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
12
+ const [isCheckingIn, setIsCheckingIn] = useState(false);
13
+
14
+ const dailyAttendance = attendance.filter(a => a.date === selectedDate);
15
+
16
+ const stats = {
17
+ present: dailyAttendance.filter(a => a.status === 'PRESENT').length,
18
+ absent: dailyAttendance.filter(a => a.status === 'ABSENT').length,
19
+ total: dailyAttendance.length
20
+ };
21
+
22
+ const handleGpsCheckIn = () => {
23
+ setIsCheckingIn(true);
24
+ if ('geolocation' in navigator) {
25
+ navigator.geolocation.getCurrentPosition(
26
+ (position) => {
27
+ setIsCheckingIn(false);
28
+ const { latitude, longitude } = position.coords;
29
+ alert(`GPS Check-in Successful!\nLat: ${latitude.toFixed(6)}\nLng: ${longitude.toFixed(6)}\nLocation verified with site geofence.`);
30
+ },
31
+ (error) => {
32
+ setIsCheckingIn(false);
33
+ alert(`Check-in failed: ${error.message}. Please ensure GPS is enabled.`);
34
+ },
35
+ { enableHighAccuracy: true, timeout: 5000, maximumAge: 0 }
36
+ );
37
+ } else {
38
+ setIsCheckingIn(false);
39
+ alert('Geolocation is not supported by your browser.');
40
+ }
41
+ };
42
+
43
+ return (
44
+ <div className="space-y-6">
45
+ <div className="flex justify-between items-center bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
46
+ <div>
47
+ <h2 className="text-xl font-bold text-slate-800">Attendance & Labor</h2>
48
+ <p className="text-sm text-slate-500">Track workforce productivity and geofenced attendance.</p>
49
+ </div>
50
+ <button
51
+ onClick={handleGpsCheckIn}
52
+ disabled={isCheckingIn}
53
+ className="flex items-center gap-2 bg-slate-800 text-white px-4 py-2 rounded-xl text-sm font-bold shadow-lg hover:bg-slate-900 transition-all disabled:opacity-50"
54
+ >
55
+ {isCheckingIn ? <Loader2 className="w-4 h-4 animate-spin" /> : <MapPin className="w-4 h-4" />}
56
+ {isCheckingIn ? 'Locating...' : 'GPS Check-In'}
57
+ </button>
58
+ </div>
59
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
60
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
61
+ <div className="flex items-center gap-3 mb-4">
62
+ <div className="w-10 h-10 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center">
63
+ <UserCheck className="w-5 h-5" />
64
+ </div>
65
+ <div>
66
+ <p className="text-xs font-bold text-slate-400 uppercase">Present Today</p>
67
+ <p className="text-2xl font-black text-slate-800">{stats.present}</p>
68
+ </div>
69
+ </div>
70
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
71
+ <div
72
+ className="h-full bg-emerald-500"
73
+ style={{ width: `${(stats.present / (stats.total || 1)) * 100}%` }}
74
+ />
75
+ </div>
76
+ </div>
77
+
78
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
79
+ <div className="flex items-center gap-3 mb-4">
80
+ <div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-xl flex items-center justify-center">
81
+ <Calendar className="w-5 h-5" />
82
+ </div>
83
+ <div>
84
+ <p className="text-xs font-bold text-slate-400 uppercase">Total Workforce</p>
85
+ <p className="text-2xl font-black text-slate-800">{stats.total}</p>
86
+ </div>
87
+ </div>
88
+ <p className="text-xs text-slate-500 font-medium">Across all categories</p>
89
+ </div>
90
+
91
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
92
+ <div className="flex items-center gap-3 mb-4">
93
+ <div className="w-10 h-10 bg-amber-50 text-amber-600 rounded-xl flex items-center justify-center">
94
+ <Clock className="w-5 h-5" />
95
+ </div>
96
+ <div>
97
+ <p className="text-xs font-bold text-slate-400 uppercase">Avg. Productivity</p>
98
+ <p className="text-2xl font-black text-slate-800">84%</p>
99
+ </div>
100
+ </div>
101
+ <p className="text-xs text-slate-500 font-medium">Based on DPR work achieved</p>
102
+ </div>
103
+ </div>
104
+
105
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
106
+ <div className="p-6 border-b border-slate-100 flex flex-wrap items-center justify-between gap-4">
107
+ <h3 className="font-bold text-slate-800">Daily Attendance Log</h3>
108
+ <div className="flex items-center gap-3">
109
+ <input
110
+ type="date"
111
+ value={selectedDate}
112
+ onChange={(e) => setSelectedDate(e.target.value)}
113
+ className="px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-blue-500"
114
+ />
115
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
116
+ <Filter className="w-5 h-5" />
117
+ </button>
118
+ </div>
119
+ </div>
120
+
121
+ <div className="overflow-x-auto">
122
+ <table className="w-full text-left border-collapse">
123
+ <thead>
124
+ <tr className="bg-slate-50/50">
125
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Worker Name</th>
126
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Category</th>
127
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Check In</th>
128
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Check Out</th>
129
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Status</th>
130
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider"></th>
131
+ </tr>
132
+ </thead>
133
+ <tbody className="divide-y divide-slate-100">
134
+ {dailyAttendance.length === 0 ? (
135
+ <tr>
136
+ <td colSpan={6} className="px-6 py-12 text-center text-slate-500 text-sm italic">
137
+ No attendance records for this date
138
+ </td>
139
+ </tr>
140
+ ) : (
141
+ dailyAttendance.map(record => (
142
+ <tr key={record.id} className="hover:bg-slate-50/50 transition-colors">
143
+ <td className="px-6 py-4">
144
+ <div className="flex items-center gap-3">
145
+ <div className="w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-xs font-bold text-slate-600">
146
+ {record.workerName.charAt(0)}
147
+ </div>
148
+ <span className="text-sm font-bold text-slate-800">{record.workerName}</span>
149
+ </div>
150
+ </td>
151
+ <td className="px-6 py-4">
152
+ <span className="text-xs font-medium text-slate-500">{record.category}</span>
153
+ </td>
154
+ <td className="px-6 py-4 text-sm text-slate-600">{record.checkIn}</td>
155
+ <td className="px-6 py-4 text-sm text-slate-600">{record.checkOut || '--:--'}</td>
156
+ <td className="px-6 py-4">
157
+ <span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
158
+ record.status === 'PRESENT' ? 'text-emerald-600 bg-emerald-50' :
159
+ record.status === 'ABSENT' ? 'text-red-600 bg-red-50' : 'text-amber-600 bg-amber-50'
160
+ }`}>
161
+ {record.status}
162
+ </span>
163
+ </td>
164
+ <td className="px-6 py-4 text-right">
165
+ <button className="p-1 text-slate-400 hover:text-slate-600">
166
+ <MoreVertical className="w-4 h-4" />
167
+ </button>
168
+ </td>
169
+ </tr>
170
+ ))
171
+ )}
172
+ </tbody>
173
+ </table>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+ };
179
+
180
+ export default AttendanceManager;
components/Auth.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { UserRole } from '../types';
3
+ import { LogIn, ShieldCheck, Construction, Calculator, Briefcase, User as UserIcon, Mail, Lock } from 'lucide-react';
4
+
5
+ interface AuthProps {
6
+ onUserChange: (user: any | null) => void;
7
+ }
8
+
9
+ const Auth: React.FC<AuthProps> = ({ onUserChange }) => {
10
+ const [isSignup, setIsSignup] = useState(false);
11
+ const [name, setName] = useState('');
12
+ const [email, setEmail] = useState('');
13
+ const [password, setPassword] = useState('');
14
+ const [role, setRole] = useState<UserRole>('DIRECTOR');
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ // Auto-login if token exists
18
+ useEffect(() => {
19
+ const token = localStorage.getItem('auth_token');
20
+ if (token) {
21
+ fetch('/api/auth/me', {
22
+ headers: { 'Authorization': `Bearer ${token}` }
23
+ })
24
+ .then(res => {
25
+ if (!res.ok) throw new Error("Invalid token");
26
+ return res.json();
27
+ })
28
+ .then(data => onUserChange(data.user))
29
+ .catch(err => {
30
+ console.error("Auto-login failed:", err);
31
+ localStorage.removeItem('auth_token');
32
+ localStorage.removeItem('local_user_uid');
33
+ });
34
+ }
35
+ }, [onUserChange]);
36
+
37
+ const handleSubmit = async (e: React.FormEvent) => {
38
+ e.preventDefault();
39
+ setError(null);
40
+
41
+ try {
42
+ const endpoint = isSignup ? '/api/auth/signup' : '/api/auth/login';
43
+ const body = isSignup
44
+ ? { name, email, password, role }
45
+ : { email, password };
46
+
47
+ const res = await fetch(endpoint, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify(body)
51
+ });
52
+
53
+ const data = await res.json();
54
+ if (!res.ok) throw new Error(data.error || (isSignup ? "Signup failed" : "Login failed"));
55
+
56
+ if (!isSignup) {
57
+ if (data.token && data.user) {
58
+ localStorage.setItem('auth_token', data.token);
59
+ localStorage.setItem('local_user_uid', data.user.uid);
60
+ onUserChange(data.user);
61
+ }
62
+ } else {
63
+ setIsSignup(false);
64
+ alert("Signup successful! Please login.");
65
+ }
66
+ } catch (e: any) {
67
+ setError(e.message);
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 p-4 font-sans">
73
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden border border-slate-200">
74
+ <div className="p-8">
75
+ <div className="text-center mb-8">
76
+ <div className="w-16 h-16 bg-blue-600 text-white rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg rotate-3">
77
+ <Construction className="w-8 h-8" />
78
+ </div>
79
+ <h1 className="text-2xl font-bold text-slate-900 mb-2 leading-tight font-heading">BuildTrack</h1>
80
+ <p className="text-slate-500 text-sm">{isSignup ? 'Create an account to begin' : 'Welcome back'}</p>
81
+ </div>
82
+
83
+ {error && <div className="mb-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg">{error}</div>}
84
+
85
+ <form onSubmit={handleSubmit} className="space-y-4">
86
+ {isSignup && (
87
+ <div className="space-y-1">
88
+ <label className="text-xs font-bold text-slate-700 uppercase tracking-wider ml-1">Your Name</label>
89
+ <div className="relative">
90
+ <UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
91
+ <input
92
+ type="text"
93
+ required
94
+ value={name}
95
+ onChange={(e) => setName(e.target.value)}
96
+ className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-600 outline-none text-sm transition-all"
97
+ placeholder="Enter your name"
98
+ />
99
+ </div>
100
+ </div>
101
+ )}
102
+
103
+ <div className="space-y-1">
104
+ <label className="text-xs font-bold text-slate-700 uppercase tracking-wider ml-1">Email</label>
105
+ <div className="relative">
106
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
107
+ <input
108
+ type="email"
109
+ required
110
+ value={email}
111
+ onChange={(e) => setEmail(e.target.value)}
112
+ className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-600 outline-none text-sm transition-all"
113
+ placeholder="Enter email"
114
+ />
115
+ </div>
116
+ </div>
117
+
118
+ <div className="space-y-1">
119
+ <label className="text-xs font-bold text-slate-700 uppercase tracking-wider ml-1">Password</label>
120
+ <div className="relative">
121
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
122
+ <input
123
+ type="password"
124
+ required
125
+ value={password}
126
+ onChange={(e) => setPassword(e.target.value)}
127
+ className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-600 outline-none text-sm transition-all"
128
+ placeholder="Enter password"
129
+ />
130
+ </div>
131
+ </div>
132
+
133
+ {isSignup && (
134
+ <div className="space-y-1 mt-4">
135
+ <label className="text-xs font-bold text-slate-700 uppercase tracking-wider ml-1">Select Role</label>
136
+ <div className="grid grid-cols-2 gap-2 mt-2">
137
+ {[
138
+ { r: 'DIRECTOR', icon: <ShieldCheck className="w-4 h-4" /> },
139
+ { r: 'MANAGER', icon: <Briefcase className="w-4 h-4" /> },
140
+ { r: 'ENGINEER', icon: <Construction className="w-4 h-4" /> },
141
+ { r: 'ACCOUNTANT', icon: <Calculator className="w-4 h-4" /> }
142
+ ].map((item) => (
143
+ <button
144
+ key={item.r}
145
+ type="button"
146
+ onClick={() => setRole(item.r as UserRole)}
147
+ className={`flex items-center gap-2 p-3 rounded-xl border text-xs font-bold transition-all ${
148
+ role === item.r
149
+ ? 'bg-blue-50 border-blue-600 text-blue-700'
150
+ : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
151
+ }`}
152
+ >
153
+ {item.icon}
154
+ {item.r}
155
+ </button>
156
+ ))}
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
+ <button
162
+ type="submit"
163
+ className="w-full flex items-center justify-center gap-2 bg-slate-900 text-white font-bold py-3 px-4 rounded-xl hover:bg-slate-800 transition-all shadow-lg active:scale-95 mt-6"
164
+ >
165
+ <LogIn className="w-4 h-4" />
166
+ {isSignup ? 'Create Account' : 'Enter Workspace'}
167
+ </button>
168
+ <button
169
+ type="button"
170
+ onClick={() => setIsSignup(!isSignup)}
171
+ className="w-full text-slate-500 py-3 text-sm hover:underline mt-2"
172
+ >
173
+ {isSignup ? 'Already have an account? Login' : 'Need an account? Sign up'}
174
+ </button>
175
+ </form>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ );
180
+ };
181
+ export default Auth;
components/BimViewer.tsx ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { BimModel } from '../types';
4
+ import { Box, Layers, Maximize2, RotateCcw, ZoomIn, ZoomOut, Search, Info, Settings, Eye, EyeOff } from 'lucide-react';
5
+
6
+ interface BimViewerProps {
7
+ models: BimModel[];
8
+ }
9
+
10
+ const BimViewer: React.FC<BimViewerProps> = ({ models }) => {
11
+ const [selectedModel, setSelectedModel] = React.useState(models[0] || null);
12
+ const [layers, setLayers] = React.useState([
13
+ { id: 'arch', name: 'Architectural', visible: true },
14
+ { id: 'struct', name: 'Structural', visible: true },
15
+ { id: 'mep', name: 'MEP', visible: false },
16
+ { id: 'elec', name: 'Electrical', visible: false },
17
+ ]);
18
+
19
+ const toggleLayer = (id: string) => {
20
+ setLayers(layers.map(l => l.id === id ? { ...l, visible: !l.visible } : l));
21
+ };
22
+
23
+ return (
24
+ <div className="h-[calc(100vh-12rem)] flex flex-col lg:flex-row gap-6">
25
+ {/* Model List & Layers */}
26
+ <div className="w-full lg:w-80 flex flex-col gap-6 shrink-0">
27
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col h-full">
28
+ <div className="p-4 border-b border-slate-100 bg-slate-50/50">
29
+ <h3 className="font-bold text-slate-800 flex items-center gap-2">
30
+ <Box className="w-4 h-4 text-blue-600" />
31
+ BIM Models
32
+ </h3>
33
+ </div>
34
+
35
+ <div className="flex-1 overflow-y-auto p-4 space-y-3">
36
+ {models.map(model => (
37
+ <button
38
+ key={model.id}
39
+ onClick={() => setSelectedModel(model)}
40
+ className={`w-full p-3 rounded-xl border text-left transition-all ${
41
+ selectedModel?.id === model.id
42
+ ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/10'
43
+ : 'border-slate-200 hover:border-slate-300'
44
+ }`}
45
+ >
46
+ <p className="text-sm font-bold text-slate-800 truncate">{model.name}</p>
47
+ <div className="flex items-center justify-between mt-1">
48
+ <span className="text-[10px] font-bold text-slate-400 uppercase">v{model.version}</span>
49
+ <span className="text-[10px] text-slate-400">{model.uploadedAt}</span>
50
+ </div>
51
+ </button>
52
+ ))}
53
+ </div>
54
+
55
+ <div className="p-4 border-t border-slate-100 bg-slate-50/50">
56
+ <h4 className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3">Model Layers</h4>
57
+ <div className="space-y-2">
58
+ {layers.map(layer => (
59
+ <button
60
+ key={layer.id}
61
+ onClick={() => toggleLayer(layer.id)}
62
+ className="w-full flex items-center justify-between p-2 hover:bg-white rounded-lg transition-all group"
63
+ >
64
+ <div className="flex items-center gap-2">
65
+ <Layers className={`w-3 h-3 ${layer.visible ? 'text-blue-600' : 'text-slate-300'}`} />
66
+ <span className={`text-xs font-medium ${layer.visible ? 'text-slate-700' : 'text-slate-400'}`}>
67
+ {layer.name}
68
+ </span>
69
+ </div>
70
+ {layer.visible ? (
71
+ <Eye className="w-3 h-3 text-blue-500" />
72
+ ) : (
73
+ <EyeOff className="w-3 h-3 text-slate-300" />
74
+ )}
75
+ </button>
76
+ ))}
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ {/* Viewer Stage */}
83
+ <div className="flex-1 bg-slate-900 rounded-2xl shadow-2xl relative overflow-hidden group">
84
+ {/* Mock 3D Canvas */}
85
+ <div className="absolute inset-0 flex items-center justify-center">
86
+ <div className="relative w-full h-full flex items-center justify-center">
87
+ {/* Simple CSS 3D Cube / Shape to simulate a model */}
88
+ <div className="w-64 h-64 relative preserve-3d animate-slow-spin">
89
+ <div className="absolute inset-0 border-2 border-blue-500/30 bg-blue-500/10 backdrop-blur-sm rounded-lg transform rotate-x-45 rotate-y-45"></div>
90
+ <div className="absolute inset-0 border-2 border-blue-400/20 bg-blue-400/5 backdrop-blur-sm rounded-lg transform -rotate-x-45 -rotate-y-45"></div>
91
+ <div className="absolute inset-0 flex items-center justify-center">
92
+ <Box className="w-32 h-32 text-blue-500/20 animate-pulse" />
93
+ </div>
94
+ </div>
95
+
96
+ {/* Grid Lines */}
97
+ <div className="absolute inset-0 opacity-10 pointer-events-none"
98
+ style={{ backgroundImage: 'radial-gradient(circle, #3b82f6 1px, transparent 1px)', backgroundSize: '40px 40px' }}></div>
99
+ </div>
100
+ </div>
101
+
102
+ {/* Viewer Controls */}
103
+ <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-slate-800/80 backdrop-blur-md p-2 rounded-2xl border border-slate-700 shadow-2xl transition-all opacity-0 group-hover:opacity-100">
104
+ <button className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-xl transition-all"><ZoomIn className="w-4 h-4" /></button>
105
+ <button className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-xl transition-all"><ZoomOut className="w-4 h-4" /></button>
106
+ <div className="w-px h-4 bg-slate-700 mx-1"></div>
107
+ <button className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-xl transition-all"><RotateCcw className="w-4 h-4" /></button>
108
+ <button className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-xl transition-all"><Maximize2 className="w-4 h-4" /></button>
109
+ <div className="w-px h-4 bg-slate-700 mx-1"></div>
110
+ <button className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-xl transition-all"><Settings className="w-4 h-4" /></button>
111
+ </div>
112
+
113
+ {/* Info Overlay */}
114
+ <div className="absolute top-6 left-6 bg-slate-800/80 backdrop-blur-md p-4 rounded-xl border border-slate-700 shadow-xl">
115
+ <p className="text-[10px] font-bold text-blue-400 uppercase tracking-widest mb-1">Active Model</p>
116
+ <p className="text-sm font-bold text-white">{selectedModel?.name || 'No Model Selected'}</p>
117
+ <div className="flex items-center gap-4 mt-3">
118
+ <div className="flex items-center gap-1.5">
119
+ <div className="w-2 h-2 rounded-full bg-emerald-500"></div>
120
+ <span className="text-[10px] text-slate-400">FPS: 60</span>
121
+ </div>
122
+ <div className="flex items-center gap-1.5">
123
+ <div className="w-2 h-2 rounded-full bg-blue-500"></div>
124
+ <span className="text-[10px] text-slate-400">Vertices: 1.2M</span>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ {/* Search / Navigation */}
130
+ <div className="absolute top-6 right-6 flex items-center gap-2">
131
+ <div className="relative">
132
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
133
+ <input
134
+ type="text"
135
+ placeholder="Find element..."
136
+ className="bg-slate-800/80 backdrop-blur-md border border-slate-700 rounded-xl pl-9 pr-4 py-2 text-xs text-white outline-none focus:ring-2 focus:ring-blue-500 w-48"
137
+ />
138
+ </div>
139
+ <button className="p-2 bg-slate-800/80 backdrop-blur-md border border-slate-700 text-slate-300 hover:text-white rounded-xl transition-all">
140
+ <Info className="w-4 h-4" />
141
+ </button>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ );
146
+ };
147
+
148
+ export default BimViewer;
components/ChangeOrderManager.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { ChangeOrder } from '../types';
4
+ import { FilePlus, Search, Filter, Plus, MoreVertical, DollarSign, Calendar, CheckCircle2, XCircle, Clock } from 'lucide-react';
5
+
6
+ interface ChangeOrderManagerProps {
7
+ changeOrders: ChangeOrder[];
8
+ }
9
+
10
+ const ChangeOrderManager: React.FC<ChangeOrderManagerProps> = ({ changeOrders }) => {
11
+ const [searchQuery, setSearchQuery] = React.useState('');
12
+
13
+ const filteredOrders = changeOrders.filter(o =>
14
+ o.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
15
+ o.description.toLowerCase().includes(searchQuery.toLowerCase())
16
+ );
17
+
18
+ const getStatusIcon = (status: string) => {
19
+ switch (status) {
20
+ case 'APPROVED': return <CheckCircle2 className="w-4 h-4 text-emerald-600" />;
21
+ case 'REJECTED': return <XCircle className="w-4 h-4 text-red-600" />;
22
+ case 'PENDING': return <Clock className="w-4 h-4 text-amber-600" />;
23
+ default: return null;
24
+ }
25
+ };
26
+
27
+ const getStatusColor = (status: string) => {
28
+ switch (status) {
29
+ case 'APPROVED': return 'text-emerald-600 bg-emerald-50 border-emerald-100';
30
+ case 'REJECTED': return 'text-red-600 bg-red-50 border-red-100';
31
+ case 'PENDING': return 'text-amber-600 bg-amber-50 border-amber-100';
32
+ default: return 'text-slate-600 bg-slate-50 border-slate-100';
33
+ }
34
+ };
35
+
36
+ return (
37
+ <div className="space-y-6">
38
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
39
+ <div className="flex items-center gap-4 flex-1 max-w-md">
40
+ <div className="relative flex-1">
41
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
42
+ <input
43
+ type="text"
44
+ placeholder="Search change orders..."
45
+ value={searchQuery}
46
+ onChange={(e) => setSearchQuery(e.target.value)}
47
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
48
+ />
49
+ </div>
50
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
51
+ <Filter className="w-5 h-5" />
52
+ </button>
53
+ </div>
54
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
55
+ <Plus className="w-4 h-4" />
56
+ New Change Order
57
+ </button>
58
+ </div>
59
+
60
+ <div className="grid grid-cols-1 gap-4">
61
+ {filteredOrders.length === 0 ? (
62
+ <div className="py-20 text-center bg-white rounded-2xl border border-dashed border-slate-300">
63
+ <FilePlus className="w-12 h-12 text-slate-300 mx-auto mb-4" />
64
+ <p className="text-slate-500 font-medium">No change orders found</p>
65
+ </div>
66
+ ) : (
67
+ filteredOrders.map(order => (
68
+ <div key={order.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group p-6">
69
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
70
+ <div className="flex-1 min-w-0">
71
+ <div className="flex items-center gap-3 mb-2">
72
+ <span className={`px-2 py-1 rounded-full border text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5 ${getStatusColor(order.status)}`}>
73
+ {getStatusIcon(order.status)}
74
+ {order.status}
75
+ </span>
76
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{order.id}</span>
77
+ </div>
78
+ <h3 className="font-bold text-slate-800 text-lg mb-1 truncate">{order.title}</h3>
79
+ <p className="text-sm text-slate-500 line-clamp-2">{order.description}</p>
80
+ </div>
81
+
82
+ <div className="flex flex-wrap items-center gap-8 shrink-0">
83
+ <div className="space-y-1">
84
+ <span className="text-[10px] font-bold text-slate-400 uppercase block">Estimated Cost</span>
85
+ <div className="flex items-center gap-1 text-slate-800 font-bold">
86
+ <DollarSign className="w-4 h-4 text-slate-400" />
87
+ <span>৳{(order.estimatedCost / 100000).toFixed(1)}L</span>
88
+ </div>
89
+ </div>
90
+
91
+ <div className="space-y-1">
92
+ <span className="text-[10px] font-bold text-slate-400 uppercase block">Requested Date</span>
93
+ <div className="flex items-center gap-1 text-slate-600 font-medium text-sm">
94
+ <Calendar className="w-4 h-4 text-slate-400" />
95
+ <span>{order.date}</span>
96
+ </div>
97
+ </div>
98
+
99
+ <div className="flex items-center gap-2">
100
+ <button className="px-4 py-2 bg-slate-900 text-white text-xs font-bold rounded-xl hover:bg-slate-800 transition-all">
101
+ View Details
102
+ </button>
103
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
104
+ <MoreVertical className="w-4 h-4" />
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ ))
111
+ )}
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default ChangeOrderManager;
components/ClientPortal.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { ProjectState } from '../types';
4
+ import { Globe, Camera, CheckCircle2, Calendar, TrendingUp, FileText, MessageSquare } from 'lucide-react';
5
+
6
+ interface ClientPortalProps {
7
+ project: ProjectState;
8
+ }
9
+
10
+ const ClientPortal: React.FC<ClientPortalProps> = ({ project }) => {
11
+ const completedMilestones = project.milestones.filter(m => m.status === 'COMPLETED');
12
+ const progress = (completedMilestones.length / (project.milestones.length || 1)) * 100;
13
+
14
+ return (
15
+ <div className="space-y-8 max-w-5xl mx-auto">
16
+ {/* Client Welcome Header */}
17
+ <div className="bg-slate-900 rounded-3xl p-8 text-white relative overflow-hidden">
18
+ <div className="relative z-10">
19
+ <div className="flex items-center gap-3 mb-4">
20
+ <div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center">
21
+ <Globe className="w-6 h-6" />
22
+ </div>
23
+ <span className="text-sm font-bold text-blue-400 uppercase tracking-widest">Client Transparency Portal</span>
24
+ </div>
25
+ <h1 className="text-3xl font-bold mb-2">Welcome back to {project.name}</h1>
26
+ <p className="text-slate-400 max-w-xl">
27
+ Track your project's progress, view site photos, and download official reports in real-time.
28
+ </p>
29
+ </div>
30
+ <div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/20 blur-[100px] rounded-full -mr-32 -mt-32"></div>
31
+ </div>
32
+
33
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
34
+ {/* Progress Overview */}
35
+ <div className="md:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
36
+ <div className="flex items-center justify-between mb-6">
37
+ <h3 className="font-bold text-slate-800">Overall Progress</h3>
38
+ <span className="text-2xl font-black text-blue-600">{progress.toFixed(0)}%</span>
39
+ </div>
40
+ <div className="w-full bg-slate-100 h-4 rounded-full overflow-hidden mb-8">
41
+ <div
42
+ className="h-full bg-blue-600 transition-all duration-1000"
43
+ style={{ width: `${progress}%` }}
44
+ />
45
+ </div>
46
+
47
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
48
+ <div className="p-4 bg-slate-50 rounded-2xl">
49
+ <p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Start Date</p>
50
+ <p className="text-sm font-bold text-slate-800">{project.startDate}</p>
51
+ </div>
52
+ <div className="p-4 bg-slate-50 rounded-2xl">
53
+ <p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Est. Completion</p>
54
+ <p className="text-sm font-bold text-slate-800">{project.endDate}</p>
55
+ </div>
56
+ <div className="p-4 bg-slate-50 rounded-2xl">
57
+ <p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Milestones</p>
58
+ <p className="text-sm font-bold text-slate-800">{completedMilestones.length}/{project.milestones.length}</p>
59
+ </div>
60
+ <div className="p-4 bg-slate-50 rounded-2xl">
61
+ <p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Site Photos</p>
62
+ <p className="text-sm font-bold text-slate-800">{project.photoLogs?.length || 0}</p>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ {/* Quick Actions */}
68
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-4">
69
+ <h3 className="font-bold text-slate-800 mb-2">Quick Actions</h3>
70
+ <button className="w-full flex items-center justify-between p-3 bg-slate-50 hover:bg-blue-50 rounded-xl transition-all group">
71
+ <div className="flex items-center gap-3">
72
+ <FileText className="w-4 h-4 text-slate-400 group-hover:text-blue-600" />
73
+ <span className="text-sm font-bold text-slate-700">Latest Report</span>
74
+ </div>
75
+ <CheckCircle2 className="w-4 h-4 text-emerald-500" />
76
+ </button>
77
+ <button className="w-full flex items-center justify-between p-3 bg-slate-50 hover:bg-blue-50 rounded-xl transition-all group">
78
+ <div className="flex items-center gap-3">
79
+ <MessageSquare className="w-4 h-4 text-slate-400 group-hover:text-blue-600" />
80
+ <span className="text-sm font-bold text-slate-700">Contact Manager</span>
81
+ </div>
82
+ </button>
83
+ </div>
84
+ </div>
85
+
86
+ {/* Recent Site Photos */}
87
+ <div className="space-y-4">
88
+ <div className="flex items-center justify-between">
89
+ <h3 className="font-bold text-slate-800 flex items-center gap-2">
90
+ <Camera className="w-5 h-5 text-blue-600" />
91
+ Recent Site Photos
92
+ </h3>
93
+ <button className="text-sm font-bold text-blue-600 hover:underline">View All</button>
94
+ </div>
95
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
96
+ {project.photoLogs?.slice(0, 4).map(photo => (
97
+ <div key={photo.id} className="aspect-square rounded-2xl overflow-hidden border border-slate-200 shadow-sm group relative">
98
+ <img
99
+ src={photo.url}
100
+ alt={photo.caption}
101
+ className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
102
+ referrerPolicy="no-referrer"
103
+ />
104
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity p-4 flex flex-col justify-end">
105
+ <p className="text-white text-[10px] font-medium">{photo.caption}</p>
106
+ <p className="text-white/70 text-[8px]">{photo.createdAt}</p>
107
+ </div>
108
+ </div>
109
+ )) || (
110
+ <div className="col-span-full py-12 text-center bg-slate-50 rounded-2xl border border-dashed border-slate-300">
111
+ <p className="text-slate-400 text-sm">No photos uploaded yet</p>
112
+ </div>
113
+ )}
114
+ </div>
115
+ </div>
116
+
117
+ {/* Milestone Timeline */}
118
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
119
+ <h3 className="font-bold text-slate-800 mb-6 flex items-center gap-2">
120
+ <Calendar className="w-5 h-5 text-blue-600" />
121
+ Project Milestones
122
+ </h3>
123
+ <div className="space-y-6 relative before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-100">
124
+ {project.milestones.map((milestone, idx) => (
125
+ <div key={milestone.id} className="flex gap-4 relative">
126
+ <div className={`w-6 h-6 rounded-full border-4 border-white shadow-sm shrink-0 z-10 ${
127
+ milestone.status === 'COMPLETED' ? 'bg-emerald-500' :
128
+ milestone.status === 'AT_RISK' ? 'bg-red-500' : 'bg-slate-300'
129
+ }`} />
130
+ <div className="flex-1 pb-6 border-b border-slate-50 last:border-0 last:pb-0">
131
+ <div className="flex items-center justify-between mb-1">
132
+ <h4 className={`text-sm font-bold ${milestone.status === 'COMPLETED' ? 'text-slate-800' : 'text-slate-500'}`}>
133
+ {milestone.title}
134
+ </h4>
135
+ <span className="text-[10px] font-bold text-slate-400">{milestone.date}</span>
136
+ </div>
137
+ <p className="text-xs text-slate-500">{milestone.description}</p>
138
+ </div>
139
+ </div>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ );
145
+ };
146
+
147
+ export default ClientPortal;
components/Collaboration.tsx ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Comment, Notification, User } from '../types';
3
+ import { MessageSquare, Bell, Send, CheckCircle2, User as UserIcon, Clock, Trash2 } from 'lucide-react';
4
+ import { useLocalCollection } from '../hooks/useLocalCollection';
5
+
6
+ interface CollaborationProps {
7
+ projectId: string;
8
+ targetId: string;
9
+ targetType: 'DOCUMENT' | 'TASK';
10
+ currentUser: User;
11
+ }
12
+
13
+ export const CommentSection: React.FC<CollaborationProps> = ({ projectId, targetId, targetType, currentUser }) => {
14
+ const { data: allComments, add: addComment, remove: removeComment } = useLocalCollection<Comment & { id: string }>(`comments_${projectId}`);
15
+ const [newComment, setNewComment] = useState('');
16
+ const [isLoading, setIsLoading] = useState(false);
17
+
18
+ // Derive sorted & filtered comments
19
+ const comments = allComments
20
+ .filter(c => c.targetId === targetId && c.targetType === targetType)
21
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
22
+
23
+ const handleSubmit = async (e: React.FormEvent) => {
24
+ e.preventDefault();
25
+ if (!newComment.trim() || !currentUser) return;
26
+
27
+ setIsLoading(true);
28
+ const commentId = `COMM-${Date.now()}`;
29
+ const commentData: Comment & { id: string } = {
30
+ id: commentId,
31
+ targetId,
32
+ targetType,
33
+ authorUid: currentUser.uid,
34
+ authorName: currentUser.name,
35
+ text: newComment.trim(),
36
+ createdAt: new Date().toISOString()
37
+ };
38
+
39
+ await addComment(commentData);
40
+
41
+ setNewComment('');
42
+ setIsLoading(false);
43
+ };
44
+
45
+ const handleDelete = async (commentId: string) => {
46
+ await removeComment(commentId);
47
+ };
48
+
49
+ return (
50
+ <div className="flex flex-col h-full bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
51
+ <div className="px-4 py-3 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
52
+ <div className="flex items-center gap-2">
53
+ <MessageSquare className="w-4 h-4 text-blue-600" />
54
+ <h4 className="font-bold text-slate-800 text-sm">Discussion</h4>
55
+ </div>
56
+ <span className="text-xs font-medium text-slate-500 bg-slate-200 px-2 py-0.5 rounded-full">
57
+ {comments.length}
58
+ </span>
59
+ </div>
60
+
61
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
62
+ {comments.length > 0 ? (
63
+ comments.map((comment) => (
64
+ <div key={comment.id} className="flex gap-3 group">
65
+ <div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center flex-shrink-0 font-bold text-xs">
66
+ {comment.authorName.charAt(0)}
67
+ </div>
68
+ <div className="flex-1">
69
+ <div className="flex items-center justify-between mb-1">
70
+ <p className="text-xs font-bold text-slate-900">{comment.authorName}</p>
71
+ <div className="flex items-center gap-2">
72
+ <p className="text-[10px] text-slate-400 flex items-center gap-1">
73
+ <Clock className="w-2.5 h-2.5" />
74
+ {new Date(comment.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
75
+ </p>
76
+ {currentUser?.uid === comment.authorUid && (
77
+ <button
78
+ onClick={() => handleDelete(comment.id)}
79
+ className="text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
80
+ >
81
+ <Trash2 className="w-3 h-3" />
82
+ </button>
83
+ )}
84
+ </div>
85
+ </div>
86
+ <div className="bg-slate-50 p-3 rounded-2xl rounded-tl-none border border-slate-100">
87
+ <p className="text-sm text-slate-700 leading-relaxed">{comment.text}</p>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ ))
92
+ ) : (
93
+ <div className="flex flex-col items-center justify-center h-full text-slate-400 opacity-50 py-8">
94
+ <MessageSquare className="w-10 h-10 mb-2" />
95
+ <p className="text-xs">No comments yet. Start the conversation!</p>
96
+ </div>
97
+ )}
98
+ </div>
99
+
100
+ <form onSubmit={handleSubmit} className="p-3 border-t border-slate-100 bg-white">
101
+ <div className="relative">
102
+ <input
103
+ type="text"
104
+ placeholder="Write a comment..."
105
+ value={newComment}
106
+ onChange={(e) => setNewComment(e.target.value)}
107
+ disabled={isLoading}
108
+ className="w-full pl-4 pr-12 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
109
+ />
110
+ <button
111
+ type="submit"
112
+ disabled={!newComment.trim() || isLoading}
113
+ className="absolute right-1.5 top-1/2 transform -translate-y-1/2 p-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all disabled:opacity-50 disabled:bg-slate-400"
114
+ >
115
+ <Send className="w-4 h-4" />
116
+ </button>
117
+ </div>
118
+ </form>
119
+ </div>
120
+ );
121
+ };
122
+
123
+ export const NotificationCenter: React.FC<{ uid: string }> = ({ uid }) => {
124
+ const { data: notifications, update: updateNotif } = useLocalCollection<Notification & { id: string }>(`notifications_${uid}`);
125
+ const [isOpen, setIsOpen] = useState(false);
126
+
127
+ const markAsRead = async (id: string) => {
128
+ updateNotif(id, { isRead: true });
129
+ };
130
+
131
+ const unreadCount = notifications.filter(n => !n.isRead).length;
132
+
133
+ return (
134
+ <div className="relative">
135
+ <button
136
+ onClick={() => setIsOpen(!isOpen)}
137
+ className="relative p-2 text-slate-500 hover:bg-slate-100 rounded-full transition-all"
138
+ >
139
+ <Bell className="w-5 h-5" />
140
+ {unreadCount > 0 && (
141
+ <span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 text-white text-[10px] font-bold flex items-center justify-center rounded-full border-2 border-white">
142
+ {unreadCount}
143
+ </span>
144
+ )}
145
+ </button>
146
+
147
+ {isOpen && (
148
+ <>
149
+ <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
150
+ <div className="absolute right-0 mt-2 w-80 bg-white rounded-2xl shadow-2xl border border-slate-200 z-50 overflow-hidden animate-in fade-in zoom-in duration-200 origin-top-right">
151
+ <div className="px-4 py-3 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
152
+ <h4 className="font-bold text-slate-800 text-sm">Notifications</h4>
153
+ {unreadCount > 0 && (
154
+ <span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">
155
+ {unreadCount} New
156
+ </span>
157
+ )}
158
+ </div>
159
+ <div className="max-h-[400px] overflow-y-auto">
160
+ {notifications.length > 0 ? (
161
+ notifications.map((n) => (
162
+ <div
163
+ key={n.id}
164
+ onClick={() => markAsRead(n.id)}
165
+ className={`p-4 border-b border-slate-50 hover:bg-slate-50 transition-all cursor-pointer flex gap-3 ${!n.isRead ? 'bg-blue-50/30' : ''}`}
166
+ >
167
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
168
+ n.type === 'TASK_ASSIGNED' ? 'bg-indigo-100 text-indigo-600' :
169
+ n.type === 'TASK_COMPLETED' ? 'bg-emerald-100 text-emerald-600' :
170
+ n.type === 'DOC_UPLOADED' ? 'bg-blue-100 text-blue-600' : 'bg-slate-100 text-slate-600'
171
+ }`}>
172
+ {n.type === 'TASK_ASSIGNED' ? <UserIcon className="w-4 h-4" /> :
173
+ n.type === 'TASK_COMPLETED' ? <CheckCircle2 className="w-4 h-4" /> :
174
+ n.type === 'DOC_UPLOADED' ? <MessageSquare className="w-4 h-4" /> : <Bell className="w-4 h-4" />}
175
+ </div>
176
+ <div className="flex-1">
177
+ <p className={`text-xs font-bold ${!n.isRead ? 'text-slate-900' : 'text-slate-600'}`}>{n.title}</p>
178
+ <p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{n.message}</p>
179
+ <p className="text-[10px] text-slate-400 mt-2 flex items-center gap-1">
180
+ <Clock className="w-2.5 h-2.5" />
181
+ {new Date(n.createdAt).toLocaleDateString()}
182
+ </p>
183
+ </div>
184
+ {!n.isRead && <div className="w-2 h-2 bg-blue-500 rounded-full mt-1.5" />}
185
+ </div>
186
+ ))
187
+ ) : (
188
+ <div className="p-8 text-center text-slate-400">
189
+ <Bell className="w-10 h-10 mx-auto mb-2 opacity-20" />
190
+ <p className="text-xs">All caught up!</p>
191
+ </div>
192
+ )}
193
+ </div>
194
+ <div className="p-3 bg-slate-50 text-center border-t border-slate-100">
195
+ <button className="text-[10px] font-bold text-slate-500 hover:text-blue-600 transition-colors uppercase tracking-widest">
196
+ View All Activity
197
+ </button>
198
+ </div>
199
+ </div>
200
+ </>
201
+ )}
202
+ </div>
203
+ );
204
+ };
components/Dashboard.tsx ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useMemo } from 'react';
3
+ import { ProjectState, Priority, AiSuggestion, BOQItem, DPR, RiskAssessment, WeatherForecast } from '../types';
4
+ import {
5
+ TrendingUp,
6
+ Activity,
7
+ AlertCircle,
8
+ Wallet,
9
+ Sparkles,
10
+ Flag,
11
+ Zap,
12
+ Check,
13
+ Trash2,
14
+ Clock,
15
+ ArrowRight,
16
+ AlertTriangle,
17
+ Calendar,
18
+ ChevronRight,
19
+ BarChart3,
20
+ Sun,
21
+ ShieldAlert,
22
+ Leaf
23
+ } from 'lucide-react';
24
+ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
25
+ import { generateProjectInsights, generatePredictiveRiskAssessment } from '../services/localAnalysisService';
26
+ import ReactMarkdown from 'react-markdown';
27
+ import RiskAssessmentComponent from './RiskAssessment';
28
+ import WeatherWidget from './WeatherWidget';
29
+
30
+ interface DashboardProps {
31
+ data: ProjectState;
32
+ onApplySuggestion: (suggestionId: string) => void;
33
+ onDismissSuggestion: (suggestionId: string) => void;
34
+ onUpdateProject?: (updater: (proj: ProjectState) => ProjectState) => void;
35
+ }
36
+
37
+ // Helper for Gantt Chart
38
+ const getDaysDiff = (d1: string, d2: string) => {
39
+ const date1 = new Date(d1);
40
+ const date2 = new Date(d2);
41
+ return Math.max(1, (date2.getTime() - date1.getTime()) / (1000 * 3600 * 24));
42
+ };
43
+
44
+ const ProjectGantt: React.FC<{ data: ProjectState }> = ({ data }) => {
45
+ const projectStart = new Date(data.startDate);
46
+ const projectEnd = new Date(data.endDate);
47
+ const totalDuration = getDaysDiff(data.startDate, data.endDate);
48
+
49
+ // Calculate Today's position relative to project duration
50
+ const today = new Date();
51
+ const todayPercent = Math.max(0, Math.min(100, (today.getTime() - projectStart.getTime()) / (projectEnd.getTime() - projectStart.getTime()) * 100));
52
+
53
+ const getPositionPercent = (dateStr: string) => {
54
+ const date = new Date(dateStr);
55
+ const diff = (date.getTime() - projectStart.getTime()) / (1000 * 3600 * 24);
56
+ return Math.max(0, Math.min(100, (diff / totalDuration) * 100));
57
+ };
58
+
59
+ // Derive Item Execution Windows from DPRs
60
+ const itemTimelines = useMemo(() => {
61
+ const timelines: { itemId: string; name: string; start: string; end: string; progress: number; priority: Priority; status: string }[] = [];
62
+
63
+ // Only look at items that have started or are high priority
64
+ const activeItems = data.boq.filter(b => b.executedQty > 0 || b.priority === 'HIGH');
65
+
66
+ activeItems.forEach(item => {
67
+ const itemDprs = data.dprs.filter(d => d.linkedBoqId === item.id).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
68
+
69
+ let startDate = itemDprs.length > 0 ? itemDprs[0].date : new Date().toISOString().split('T')[0];
70
+ let endDate = itemDprs.length > 0 ? itemDprs[itemDprs.length - 1].date : startDate;
71
+
72
+ // If only one DPR or duration is 0, give it a visual width (e.g. 15 days)
73
+ if (startDate === endDate) {
74
+ const end = new Date(startDate);
75
+ end.setDate(end.getDate() + 15);
76
+ endDate = end.toISOString().split('T')[0];
77
+ }
78
+
79
+ // If item hasn't started (no DPRs) but is High Priority, visualize it as "Planned" starting now
80
+ if (itemDprs.length === 0) {
81
+ startDate = new Date().toISOString().split('T')[0];
82
+ const end = new Date(startDate);
83
+ end.setDate(end.getDate() + 30);
84
+ endDate = end.toISOString().split('T')[0];
85
+ }
86
+
87
+ const progress = Math.min(100, (item.executedQty / item.plannedQty) * 100);
88
+
89
+ let status = 'On Track';
90
+ if (progress < 100 && new Date(endDate) < today) status = 'Delayed';
91
+ if (progress >= 100) status = 'Completed';
92
+
93
+ timelines.push({
94
+ itemId: item.id,
95
+ name: item.description,
96
+ start: startDate,
97
+ end: endDate,
98
+ progress,
99
+ priority: item.priority || 'LOW',
100
+ status
101
+ });
102
+ });
103
+
104
+ return timelines.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()).slice(0, 10);
105
+ }, [data.boq, data.dprs]);
106
+
107
+ return (
108
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
109
+ <div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50 flex justify-between items-center">
110
+ <div className="flex items-center gap-2">
111
+ <BarChart3 className="w-5 h-5 text-blue-600" />
112
+ <h3 className="font-bold text-slate-900 tracking-tight">Timeline & Progress</h3>
113
+ </div>
114
+ <div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-wider text-slate-500">
115
+ <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-blue-600"></div> High Priority</div>
116
+ <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-blue-400"></div> Scheduled</div>
117
+ <div className="flex items-center gap-1.5 text-rose-500"><div className="w-2.5 h-0.5 bg-rose-500"></div> Today</div>
118
+ </div>
119
+ </div>
120
+
121
+ <div className="p-6 overflow-x-auto">
122
+ <div className="min-w-[700px] relative">
123
+ {/* Timeline Header (Months) */}
124
+ <div className="flex border-b border-slate-200 pb-2 mb-4 text-xs text-slate-400 font-medium font-mono uppercase tracking-widest relative h-6">
125
+ <span className="absolute left-0">{data.startDate}</span>
126
+ <span className="absolute left-1/2 -translate-x-1/2">Project Duration ({Math.ceil(totalDuration)} Days)</span>
127
+ <span className="absolute right-0">{data.endDate}</span>
128
+ </div>
129
+
130
+ {/* Grid Lines */}
131
+ <div className="absolute inset-0 top-8 pointer-events-none flex justify-between px-[1px]">
132
+ {[0, 25, 50, 75, 100].map(p => (
133
+ <div key={p} className="h-full w-px bg-slate-100 last:bg-transparent" style={{ left: `${p}%` }}></div>
134
+ ))}
135
+ </div>
136
+
137
+ {/* Today Marker */}
138
+ <div className="absolute top-8 bottom-0 w-px border-l-2 border-red-400 border-dashed z-30 pointer-events-none" style={{ left: `${todayPercent}%` }}>
139
+ <div className="absolute -top-4 -translate-x-1/2 bg-red-50 text-red-600 text-[9px] font-bold px-1.5 py-0.5 rounded border border-red-100 uppercase tracking-wider">
140
+ Today
141
+ </div>
142
+ </div>
143
+
144
+ {/* Milestones Track */}
145
+ <div className="h-12 relative mb-6">
146
+ <div className="absolute top-1/2 left-0 w-full h-1 bg-slate-100 -translate-y-1/2 rounded-full"></div>
147
+ {data.milestones.map(m => {
148
+ const pos = getPositionPercent(m.date);
149
+ return (
150
+ <div
151
+ key={m.id}
152
+ className="absolute top-1/2 -translate-y-1/2 group z-20 cursor-pointer"
153
+ style={{ left: `${pos}%` }}
154
+ >
155
+ <div className={`w-3.5 h-3.5 rotate-45 border-2 border-white shadow-md transition-transform hover:scale-125 ${
156
+ m.status === 'COMPLETED' ? 'bg-emerald-500' :
157
+ m.status === 'AT_RISK' ? 'bg-red-500' : 'bg-slate-400'
158
+ }`}></div>
159
+
160
+ {/* Tooltip for Milestone */}
161
+ <div className="absolute top-6 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 bg-slate-800 text-white text-[10px] px-2 py-1.5 rounded whitespace-nowrap transition-opacity pointer-events-none z-40 shadow-xl">
162
+ <div className="font-bold">{m.title}</div>
163
+ <div className="text-slate-300">{m.date} • {m.status}</div>
164
+ </div>
165
+
166
+ {/* Date label always visible if needed, but keeping it clean */}
167
+ <div className="absolute -top-5 left-1/2 -translate-x-1/2 text-[9px] font-bold text-slate-400 whitespace-nowrap opacity-50 group-hover:opacity-100">
168
+ {m.date.split('-').slice(1).join('/')}
169
+ </div>
170
+ </div>
171
+ );
172
+ })}
173
+ </div>
174
+
175
+ {/* Items Tracks */}
176
+ <div className="space-y-4 pt-4">
177
+ {itemTimelines.map(item => {
178
+ const left = getPositionPercent(item.start);
179
+ const width = Math.max(2, getPositionPercent(item.end) - left); // Min width 2%
180
+
181
+ return (
182
+ <div key={item.itemId} className="relative h-14 group">
183
+ <div className="flex justify-between items-center text-xs mb-1.5 px-1">
184
+ <div className="flex flex-col">
185
+ <span className="font-bold text-slate-700 truncate max-w-[250px]" title={item.name}>{item.name}</span>
186
+ <span className="text-[10px] text-slate-400 font-mono tracking-tighter uppercase">{item.start} — {item.end}</span>
187
+ </div>
188
+ <div className="flex items-center gap-3">
189
+ <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full border ${
190
+ item.status === 'Completed' ? 'bg-emerald-50 text-emerald-700 border-emerald-100' :
191
+ item.status === 'Delayed' ? 'bg-rose-50 text-rose-700 border-rose-100' :
192
+ 'bg-indigo-50 text-indigo-700 border-indigo-100'
193
+ }`}>{item.status}</span>
194
+ <span className="font-mono font-bold text-slate-800 text-sm">{item.progress.toFixed(0)}%</span>
195
+ </div>
196
+ </div>
197
+ <div className="w-full h-4 bg-slate-100 rounded-lg overflow-hidden relative shadow-inner">
198
+ {/* The Background Bar (Planned/Total Window) */}
199
+ <div
200
+ className={`absolute h-full rounded-lg transition-all duration-500 flex items-center border border-white/20 ${
201
+ item.priority === 'HIGH' ? 'bg-indigo-500/10' : 'bg-slate-300/10'
202
+ }`}
203
+ style={{ left: `${left}%`, width: `${width}%` }}
204
+ ></div>
205
+
206
+ {/* The Actual Progress Bar */}
207
+ <div
208
+ className={`absolute h-full rounded-lg transition-all duration-700 shadow-sm flex items-center overflow-hidden border border-white/30 ${
209
+ item.status === 'Delayed' ? 'bg-rose-500' :
210
+ item.status === 'Completed' ? 'bg-emerald-500' :
211
+ item.priority === 'HIGH' ? 'bg-indigo-600' : 'bg-blue-500'
212
+ }`}
213
+ style={{ left: `${left}%`, width: `${(width * item.progress) / 100}%` }}
214
+ >
215
+ {/* Glossy overlay */}
216
+ <div className="absolute inset-0 bg-gradient-to-b from-white/20 to-transparent"></div>
217
+ {/* Animated stripes if active */}
218
+ {item.status !== 'Completed' && (
219
+ <div className="absolute inset-0 opacity-20 bg-[linear-gradient(45deg,rgba(255,255,255,.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,.15)_50%,rgba(255,255,255,.15)_75%,transparent_75%,transparent)] bg-[length:20px_20px] animate-[pulse_2s_infinite]"></div>
220
+ )}
221
+ </div>
222
+ </div>
223
+
224
+ {/* Hover Details */}
225
+ <div className="absolute -top-12 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 bg-slate-900 text-white text-[10px] px-3 py-2 rounded-lg z-40 pointer-events-none w-max shadow-2xl transition-all duration-200 transform group-hover:-translate-y-1">
226
+ <div className="font-bold border-b border-slate-700 pb-1 mb-1">{item.name}</div>
227
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1">
228
+ <div><span className="text-slate-400">Duration:</span> {getDaysDiff(item.start, item.end).toFixed(0)}d</div>
229
+ <div><span className="text-slate-400">Progress:</span> {item.progress.toFixed(1)}%</div>
230
+ <div><span className="text-slate-400">Start:</span> {item.start}</div>
231
+ <div><span className="text-slate-400">End:</span> {item.end}</div>
232
+ </div>
233
+ <div className="absolute bottom-[-4px] left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45"></div>
234
+ </div>
235
+ </div>
236
+ );
237
+ })}
238
+ {itemTimelines.length === 0 && (
239
+ <div className="text-center py-8 text-xs text-slate-400 italic bg-slate-50/50 rounded border border-dashed border-slate-200">
240
+ No active work timelines generated yet. Add DPRs to visualize progress.
241
+ </div>
242
+ )}
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ );
248
+ };
249
+
250
+ const Dashboard: React.FC<DashboardProps> = ({ data, onApplySuggestion, onDismissSuggestion, onUpdateProject }) => {
251
+ const [insight, setInsight] = useState<string | null>(null);
252
+ const [loadingInsight, setLoadingInsight] = useState(false);
253
+ const [loadingRisk, setLoadingRisk] = useState(false);
254
+
255
+ // Calculate high-level metrics
256
+ const totalPlannedValue = data.boq.reduce((sum, item) => sum + (item.plannedQty * item.rate), 0);
257
+ const totalExecutedValue = data.boq.reduce((sum, item) => sum + (item.executedQty * item.rate), 0);
258
+ const progressPercentage = Math.round((totalExecutedValue / totalPlannedValue) * 100) || 0;
259
+
260
+ const totalLiabilities = data.liabilities.reduce((sum, item) => sum + item.amount, 0);
261
+ const totalBilled = data.bills.filter(b => b.type === 'CLIENT_RA').reduce((sum, b) => sum + b.amount, 0);
262
+
263
+ // Calculate Pending Work for Key Points
264
+ const pendingHighPriorityItems = data.boq
265
+ .filter(item => item.priority === 'HIGH' && item.executedQty < item.plannedQty)
266
+ .map(item => ({
267
+ ...item,
268
+ pendingQty: item.plannedQty - item.executedQty,
269
+ pendingValue: (item.plannedQty - item.executedQty) * item.rate,
270
+ progress: (item.executedQty / item.plannedQty) * 100
271
+ }))
272
+ .sort((a, b) => b.pendingValue - a.pendingValue)
273
+ .slice(0, 5);
274
+
275
+ const chartData = [
276
+ { name: 'Planned', amount: totalPlannedValue },
277
+ { name: 'Executed', amount: totalExecutedValue },
278
+ { name: 'Billed', amount: totalBilled },
279
+ { name: 'Liabilities', amount: totalLiabilities },
280
+ ];
281
+
282
+ const handleGenerateInsights = async () => {
283
+ setLoadingInsight(true);
284
+ const result = await generateProjectInsights(data);
285
+ setInsight(result);
286
+ setLoadingInsight(false);
287
+ };
288
+
289
+ const handleGenerateRisks = async () => {
290
+ if (!onUpdateProject) return;
291
+ setLoadingRisk(true);
292
+ const result = await generatePredictiveRiskAssessment(data);
293
+ if (result) {
294
+ onUpdateProject(proj => ({ ...proj, riskAssessment: result }));
295
+ }
296
+ setLoadingRisk(false);
297
+ };
298
+
299
+ const getPriorityColor = (p: Priority) => {
300
+ switch(p) {
301
+ case 'HIGH': return 'bg-red-50 text-red-700 border-red-100';
302
+ case 'MEDIUM': return 'bg-amber-50 text-amber-700 border-amber-100';
303
+ case 'LOW': return 'bg-blue-50 text-blue-700 border-blue-100';
304
+ default: return 'bg-slate-50 text-slate-700 border-slate-100';
305
+ }
306
+ };
307
+
308
+ const pendingSuggestions = data.aiSuggestions.filter(s => s.status === 'PENDING');
309
+
310
+ return (
311
+ <div className="space-y-8 animate-in fade-in duration-500">
312
+ <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
313
+ <div>
314
+ <div className="flex items-center gap-3 mb-1">
315
+ <h1 className="text-3xl font-bold text-slate-900 tracking-tight">Project Overview</h1>
316
+ <div className={`px-2 py-0.5 rounded text-[10px] font-bold border uppercase tracking-wider flex items-center gap-1 ${getPriorityColor(data.priority)}`}>
317
+ <Flag className="w-2.5 h-2.5" />
318
+ {data.priority} Priority
319
+ </div>
320
+ </div>
321
+ <p className="text-slate-500 font-medium">Monitoring and insights for <span className="text-slate-900">{data.name}</span></p>
322
+ </div>
323
+ <div className="flex items-center gap-3">
324
+ {onUpdateProject && (
325
+ <button
326
+ onClick={handleGenerateRisks}
327
+ disabled={loadingRisk}
328
+ className="flex items-center gap-2 bg-slate-800 text-white px-4 py-2 rounded-lg hover:bg-slate-900 transition-colors shadow-sm text-sm font-medium disabled:opacity-50"
329
+ >
330
+ <ShieldAlert className="w-4 h-4" />
331
+ {loadingRisk ? 'Modeling Risks...' : 'Predict Risks'}
332
+ </button>
333
+ )}
334
+ <button
335
+ onClick={handleGenerateInsights}
336
+ disabled={loadingInsight}
337
+ className="btn-primary flex items-center gap-2"
338
+ >
339
+ <Sparkles className="w-4 h-4" />
340
+ {loadingInsight ? 'Analyzing Data...' : 'AI Insights'}
341
+ </button>
342
+ </div>
343
+ </div>
344
+
345
+ {/* Mini Health Dashboard */}
346
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
347
+ <div className="card p-5 flex items-center gap-4">
348
+ <div className={`p-3 rounded-xl ${data.riskAssessment?.overallRiskScore && data.riskAssessment.overallRiskScore > 70 ? 'bg-red-50 text-red-600' : 'bg-emerald-50 text-emerald-600'}`}>
349
+ <ShieldAlert className="w-5 h-5" />
350
+ </div>
351
+ <div>
352
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Risk Score</p>
353
+ <p className="text-xl font-bold text-slate-900">{data.riskAssessment?.overallRiskScore || 0}%</p>
354
+ </div>
355
+ </div>
356
+ <div className="card p-5 flex items-center gap-4">
357
+ <div className="p-3 bg-amber-50 text-amber-600 rounded-xl">
358
+ <Sun className="w-5 h-5" />
359
+ </div>
360
+ <div>
361
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Weather Impact</p>
362
+ <p className="text-xl font-bold text-slate-900">
363
+ {data.weatherForecast?.find(f => f.impactOnSite !== 'NONE') ? 'Active Advisory' : 'Operational'}
364
+ </p>
365
+ </div>
366
+ </div>
367
+ <div className="card p-5 flex items-center gap-4 sm:col-span-2 lg:col-span-1">
368
+ <div className="p-3 bg-blue-50 text-blue-600 rounded-xl">
369
+ <Activity className="w-5 h-5" />
370
+ </div>
371
+ <div>
372
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Execution Index</p>
373
+ <p className="text-xl font-bold text-slate-900">
374
+ {progressPercentage > 0 ? (progressPercentage / getDaysDiff(data.startDate, new Date().toISOString()) * 100).toFixed(1) : '0.0'}% avg.
375
+ </p>
376
+ </div>
377
+ </div>
378
+ </div>
379
+
380
+ <div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
381
+ <div className="xl:col-span-2 space-y-8">
382
+ {/* KPI Cards */}
383
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
384
+ <div className="card p-5 flex flex-col justify-between">
385
+ <div className="flex justify-between items-start">
386
+ <div>
387
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Completed</p>
388
+ <h3 className="text-2xl font-bold text-slate-900">{progressPercentage}%</h3>
389
+ </div>
390
+ <div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
391
+ <Activity className="w-4 h-4" />
392
+ </div>
393
+ </div>
394
+ <div className="w-full bg-slate-100 h-1.5 mt-4 rounded-full overflow-hidden">
395
+ <div className="bg-blue-600 h-full rounded-full" style={{ width: `${progressPercentage}%` }}></div>
396
+ </div>
397
+ </div>
398
+
399
+ <div className="card p-5 flex flex-col justify-between">
400
+ <div className="flex justify-between items-start">
401
+ <div>
402
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Contract</p>
403
+ <h3 className="text-2xl font-bold text-slate-900">৳{(data.contractValue / 1000000).toFixed(1)}M</h3>
404
+ </div>
405
+ <div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
406
+ <TrendingUp className="w-4 h-4" />
407
+ </div>
408
+ </div>
409
+ <p className="text-[10px] text-slate-400 font-medium mt-4">Total Budget</p>
410
+ </div>
411
+
412
+ <div className="card p-5 flex flex-col justify-between">
413
+ <div className="flex justify-between items-start">
414
+ <div>
415
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Billed</p>
416
+ <h3 className="text-2xl font-bold text-slate-900">৳{totalBilled > 1000 ? (totalBilled / 1000).toFixed(0) + 'k' : totalBilled}</h3>
417
+ </div>
418
+ <div className="p-2 bg-violet-50 text-violet-600 rounded-lg">
419
+ <Wallet className="w-4 h-4" />
420
+ </div>
421
+ </div>
422
+ <p className="text-[10px] text-slate-400 font-medium mt-4">Certified Work</p>
423
+ </div>
424
+
425
+ <div className="card p-5 flex flex-col justify-between">
426
+ <div className="flex justify-between items-start">
427
+ <div>
428
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Liabilities</p>
429
+ <h3 className="text-2xl font-bold text-rose-600">৳{totalLiabilities > 1000 ? (totalLiabilities / 1000).toFixed(0) + 'k' : totalLiabilities}</h3>
430
+ </div>
431
+ <div className="p-2 bg-rose-50 text-rose-600 rounded-lg">
432
+ <AlertCircle className="w-4 h-4" />
433
+ </div>
434
+ </div>
435
+ <p className="text-[10px] text-slate-400 font-medium mt-4">Unpaid Sum</p>
436
+ </div>
437
+
438
+ <div className="card p-5 flex flex-col justify-between">
439
+ <div className="flex justify-between items-start">
440
+ <div>
441
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Emissions</p>
442
+ <h3 className="text-2xl font-bold text-emerald-600">{data.sustainabilityMetrics?.carbonFootprint || 0}kg</h3>
443
+ </div>
444
+ <div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
445
+ <Leaf className="w-4 h-4" />
446
+ </div>
447
+ </div>
448
+ <p className="text-[10px] text-slate-400 font-medium mt-4">Carbon Output</p>
449
+ </div>
450
+ </div>
451
+
452
+ {/* Gantt Chart Section */}
453
+ <ProjectGantt data={data} />
454
+
455
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
456
+ {data.riskAssessment && <RiskAssessmentComponent assessment={data.riskAssessment} />}
457
+ {data.weatherForecast && <WeatherWidget forecast={data.weatherForecast} />}
458
+ </div>
459
+
460
+ {/* KEY POINTS: Pending Progress Table */}
461
+ <div className="card overflow-hidden">
462
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50/30">
463
+ <div className="flex items-center gap-2">
464
+ <AlertTriangle className="w-5 h-5 text-amber-500" />
465
+ <h3 className="font-bold text-slate-900 tracking-tight">Critical Pending Work</h3>
466
+ </div>
467
+ <span className="text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 px-2 py-0.5 rounded-full uppercase tracking-wider">Action Needed</span>
468
+ </div>
469
+ <div className="overflow-x-auto">
470
+ <table className="w-full text-left text-sm">
471
+ <thead className="bg-slate-50/50 text-[10px] text-slate-500 font-bold uppercase tracking-widest border-b border-slate-200">
472
+ <tr>
473
+ <th className="px-6 py-4">Item Description</th>
474
+ <th className="px-6 py-4 text-right">Balance</th>
475
+ <th className="px-6 py-4 text-right">Value</th>
476
+ <th className="px-6 py-4 text-right">Progress</th>
477
+ </tr>
478
+ </thead>
479
+ <tbody className="divide-y divide-slate-100">
480
+ {pendingHighPriorityItems.length > 0 ? pendingHighPriorityItems.map(item => (
481
+ <tr key={item.id} className="hover:bg-slate-50 group transition-colors">
482
+ <td className="px-6 py-4">
483
+ <div className="font-bold text-slate-800 line-clamp-1">{item.description}</div>
484
+ <div className="text-[10px] text-slate-400 font-mono tracking-tighter uppercase">{item.id}</div>
485
+ </td>
486
+ <td className="px-6 py-4 text-right text-slate-600 font-mono text-xs">
487
+ {item.pendingQty.toLocaleString()} <span className="text-[10px] text-slate-400 uppercase tracking-tighter">{item.unit}</span>
488
+ </td>
489
+ <td className="px-6 py-4 text-right font-bold text-slate-900 font-mono text-xs">
490
+ ৳{item.pendingValue.toLocaleString()}
491
+ </td>
492
+ <td className="px-6 py-4">
493
+ <div className="flex items-center justify-end gap-3">
494
+ <div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
495
+ <div className="h-full bg-blue-600 rounded-full" style={{ width: `${item.progress}%` }}></div>
496
+ </div>
497
+ <span className="text-xs font-bold text-slate-500 w-8 text-right">{item.progress.toFixed(0)}%</span>
498
+ </div>
499
+ </td>
500
+ </tr>
501
+ )) : (
502
+ <tr>
503
+ <td colSpan={4} className="p-12 text-center text-slate-400">
504
+ <Check className="w-10 h-10 mx-auto mb-3 text-emerald-400 opacity-50" />
505
+ <p className="font-medium">No high-priority pending items detected.</p>
506
+ </td>
507
+ </tr>
508
+ )}
509
+ </tbody>
510
+ </table>
511
+ </div>
512
+ </div>
513
+
514
+ {/* Financial Chart */}
515
+ <div className="card p-6">
516
+ <div className="flex items-center justify-between mb-8">
517
+ <h3 className="text-lg font-bold text-slate-900 tracking-tight">Financial Position</h3>
518
+ <div className="flex gap-4">
519
+ <div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-blue-600"></div> <span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Budget</span></div>
520
+ <div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-emerald-600"></div> <span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Done</span></div>
521
+ </div>
522
+ </div>
523
+ <div className="h-64 w-full">
524
+ <ResponsiveContainer width="100%" height="100%">
525
+ <BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
526
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
527
+ <XAxis
528
+ dataKey="name"
529
+ axisLine={false}
530
+ tickLine={false}
531
+ tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 700 }}
532
+ dy={10}
533
+ />
534
+ <YAxis
535
+ axisLine={false}
536
+ tickLine={false}
537
+ tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 700 }}
538
+ tickFormatter={(value) => `৳${value >= 1000000 ? (value/1000000).toFixed(1) + 'M' : (value/1000).toFixed(0) + 'k'}`}
539
+ />
540
+ <Tooltip
541
+ cursor={{ fill: '#f8fafc' }}
542
+ contentStyle={{ borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', padding: '12px' }}
543
+ labelStyle={{ fontWeight: 800, color: '#0f172a', marginBottom: '4px', fontSize: '12px' }}
544
+ itemStyle={{ fontSize: '11px', fontWeight: 600 }}
545
+ formatter={(value: number) => [`৳${value.toLocaleString()}`, 'Amount']}
546
+ />
547
+ <Bar dataKey="amount" radius={[4, 4, 0, 0]} barSize={40}>
548
+ {chartData.map((entry, index) => (
549
+ <Cell key={`cell-${index}`} fill={['#2563eb', '#059669', '#7c3aed', '#ea580c'][index]} />
550
+ ))}
551
+ </Bar>
552
+ </BarChart>
553
+ </ResponsiveContainer>
554
+ </div>
555
+ </div>
556
+
557
+ {insight && (
558
+ <div className="bg-white border border-slate-200 rounded-2xl p-8 shadow-sm relative overflow-hidden animate-in slide-in-from-bottom-5">
559
+ <div className="absolute top-0 left-0 w-1.5 h-full bg-blue-600"></div>
560
+ <div className="flex items-center gap-3 mb-6">
561
+ <div className="p-2 bg-blue-50 rounded-lg">
562
+ <Sparkles className="w-5 h-5 text-blue-600" />
563
+ </div>
564
+ <div>
565
+ <h3 className="text-lg font-bold text-slate-900 tracking-tight">AI Strategy & Insights</h3>
566
+ <p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest leading-none">Generative Analysis</p>
567
+ </div>
568
+ </div>
569
+ <div className="prose prose-slate max-w-none text-slate-700 text-sm leading-relaxed">
570
+ <ReactMarkdown>{insight}</ReactMarkdown>
571
+ </div>
572
+ </div>
573
+ )}
574
+ </div>
575
+
576
+ {/* Sidebar */}
577
+ <div className="space-y-6">
578
+
579
+ {/* NEW KEY POINTS: Milestone Tracker */}
580
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
581
+ <div className="p-4 border-b border-slate-200 bg-slate-50/50">
582
+ <div className="flex items-center gap-2">
583
+ <Flag className="w-5 h-5 text-indigo-500" />
584
+ <h3 className="font-bold text-slate-800">Key Project Points</h3>
585
+ </div>
586
+ </div>
587
+ <div className="p-4 space-y-4">
588
+ {data.milestones && data.milestones.length > 0 ? data.milestones.map((milestone) => (
589
+ <div key={milestone.id} className="relative pl-4 border-l-2 border-slate-200 last:mb-0">
590
+ <div className={`absolute -left-[5px] top-1 w-2.5 h-2.5 rounded-full border-2 border-white shadow-sm ${
591
+ milestone.status === 'COMPLETED' ? 'bg-emerald-500' :
592
+ milestone.status === 'AT_RISK' ? 'bg-red-500' : 'bg-slate-300'
593
+ }`}></div>
594
+ <div className="flex justify-between items-start">
595
+ <h4 className={`text-sm font-semibold ${milestone.status === 'COMPLETED' ? 'text-slate-400 line-through' : 'text-slate-800'}`}>
596
+ {milestone.title}
597
+ </h4>
598
+ <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
599
+ milestone.status === 'COMPLETED' ? 'bg-emerald-50 text-emerald-600' :
600
+ milestone.status === 'AT_RISK' ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-500'
601
+ }`}>{milestone.status.replace('_', ' ')}</span>
602
+ </div>
603
+ <div className="flex items-center gap-1.5 mt-1 text-xs text-slate-500">
604
+ <Calendar className="w-3 h-3" />
605
+ <span>{milestone.date}</span>
606
+ </div>
607
+ {milestone.description && <p className="text-xs text-slate-400 mt-1">{milestone.description}</p>}
608
+ </div>
609
+ )) : (
610
+ <div className="text-center py-4 text-slate-400 text-sm">No milestones tracked.</div>
611
+ )}
612
+ </div>
613
+ </div>
614
+
615
+ {/* AI Action Sidebar */}
616
+ <div className="bg-slate-900 rounded-xl p-6 text-white shadow-xl relative overflow-hidden">
617
+ <div className="absolute -right-10 -top-10 w-40 h-40 bg-indigo-500/20 rounded-full blur-3xl"></div>
618
+ <div className="relative z-10">
619
+ <div className="flex items-center gap-2 mb-4">
620
+ <Zap className="w-5 h-5 text-indigo-400 fill-current" />
621
+ <h3 className="font-bold text-lg">AI Action Feed</h3>
622
+ </div>
623
+ <p className="text-slate-400 text-sm mb-6">Actionable data extracted from your recent document scans.</p>
624
+
625
+ <div className="space-y-4">
626
+ {pendingSuggestions.length > 0 ? pendingSuggestions.map((s) => (
627
+ <div key={s.id} className="bg-slate-800/50 border border-slate-700 rounded-lg p-4 hover:border-indigo-500 transition-all group">
628
+ <div className="flex justify-between items-start mb-2">
629
+ <span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase tracking-wider ${
630
+ s.type === 'QUANTITY_UPDATE' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
631
+ s.type === 'BILL_DETECTION' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
632
+ 'bg-red-500/20 text-red-400 border-red-500/30'
633
+ }`}>
634
+ {s.type.replace('_', ' ')}
635
+ </span>
636
+ <Clock className="w-3.5 h-3.5 text-slate-500" />
637
+ </div>
638
+ <h4 className="text-sm font-bold text-white mb-1 group-hover:text-indigo-400 transition-colors">{s.title}</h4>
639
+ <p className="text-xs text-slate-400 line-clamp-2 mb-4">{s.description}</p>
640
+
641
+ <div className="flex gap-2">
642
+ <button
643
+ onClick={() => onApplySuggestion(s.id)}
644
+ className="flex-1 flex items-center justify-center gap-1.5 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-xs font-bold transition-colors"
645
+ >
646
+ <Check className="w-3.5 h-3.5" />
647
+ Apply
648
+ </button>
649
+ <button
650
+ onClick={() => onDismissSuggestion(s.id)}
651
+ className="p-1.5 bg-slate-700 hover:bg-red-900/40 text-slate-400 hover:text-red-400 rounded transition-colors"
652
+ >
653
+ <Trash2 className="w-3.5 h-3.5" />
654
+ </button>
655
+ </div>
656
+ </div>
657
+ )) : (
658
+ <div className="text-center py-10">
659
+ <Activity className="w-10 h-10 text-slate-700 mx-auto mb-3 opacity-30" />
660
+ <p className="text-slate-500 text-sm">No pending actions. Try an AI Deep Scan on your documents.</p>
661
+ </div>
662
+ )}
663
+ </div>
664
+
665
+ {pendingSuggestions.length > 0 && (
666
+ <button className="w-full mt-6 text-xs text-indigo-400 font-bold flex items-center justify-center gap-1 hover:text-indigo-300 transition-colors">
667
+ View All Suggestions
668
+ <ArrowRight className="w-3 h-3" />
669
+ </button>
670
+ )}
671
+ </div>
672
+ </div>
673
+ </div>
674
+ </div>
675
+ </div>
676
+ );
677
+ };
678
+
679
+ export default Dashboard;
components/DocumentManager.tsx ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { ProjectDocument, DocumentCategory, ModuleType, BOQItem, ExtractedBill } from '../types';
4
+ import {
5
+ FileText,
6
+ Image,
7
+ File,
8
+ Search,
9
+ UploadCloud,
10
+ Download,
11
+ X,
12
+ FileSpreadsheet,
13
+ Paperclip,
14
+ Loader2,
15
+ CheckCircle2,
16
+ Sparkles,
17
+ Zap,
18
+ Tag
19
+ } from 'lucide-react';
20
+ import { analyzeDocumentContent, suggestDocumentMetadata, extractBillData } from '../services/localAnalysisService';
21
+
22
+ interface DocumentManagerProps {
23
+ documents: ProjectDocument[];
24
+ onAddDocument: (doc: ProjectDocument) => void;
25
+ onAnalyzeDocument?: (docId: string, suggestions: any[]) => void;
26
+ onSelectDocument?: (docId: string | null) => void;
27
+ onBillUploaded?: (data: ExtractedBill) => void;
28
+ boqItems?: BOQItem[];
29
+ filterModule?: ModuleType;
30
+ compact?: boolean;
31
+ allowUpload?: boolean;
32
+ }
33
+
34
+ const DocumentManager: React.FC<DocumentManagerProps> = ({
35
+ documents,
36
+ onAddDocument,
37
+ onAnalyzeDocument,
38
+ onBillUploaded,
39
+ boqItems = [],
40
+ filterModule,
41
+ compact = false,
42
+ allowUpload = true
43
+ }) => {
44
+ const [searchTerm, setSearchTerm] = useState('');
45
+ const [selectedCategory, setSelectedCategory] = useState<DocumentCategory | 'ALL'>('ALL');
46
+ const [selectedModule, setSelectedModule] = useState<ModuleType | 'ALL'>('ALL');
47
+ const [dateFrom, setDateFrom] = useState('');
48
+ const [dateTo, setDateTo] = useState('');
49
+ const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
50
+ const [analyzingDocId, setAnalyzingDocId] = useState<string | null>(null);
51
+
52
+ // Upload Form State
53
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
54
+ const [newDocName, setNewDocName] = useState('');
55
+ const [newDocType, setNewDocType] = useState('PDF');
56
+ const [newDocCategory, setNewDocCategory] = useState<DocumentCategory>('REPORT');
57
+ const [newDocModule, setNewDocModule] = useState<ModuleType>(filterModule || 'GENERAL');
58
+ const [isUploading, setIsUploading] = useState(false);
59
+ const [isSuggestingMetadata, setIsSuggestingMetadata] = useState(false);
60
+ const [isAnalyzingBill, setIsAnalyzingBill] = useState(false);
61
+ const [hasAiSuggested, setHasAiSuggested] = useState(false);
62
+ const [newDocTags, setNewDocTags] = useState('');
63
+
64
+ const filteredDocs = documents.filter(doc => {
65
+ const matchesSearch = doc.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
66
+ (doc.tags && doc.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())));
67
+ const matchesCategory = selectedCategory === 'ALL' || doc.category === selectedCategory;
68
+ const matchesModule = filterModule
69
+ ? doc.module === filterModule
70
+ : (selectedModule === 'ALL' || doc.module === selectedModule);
71
+
72
+ const matchesDate = (!dateFrom || doc.uploadDate >= dateFrom) &&
73
+ (!dateTo || doc.uploadDate <= dateTo);
74
+
75
+ return matchesSearch && matchesCategory && matchesModule && matchesDate;
76
+ });
77
+
78
+ const handleDeepScan = async (doc: ProjectDocument) => {
79
+ if (!onAnalyzeDocument) return;
80
+ setAnalyzingDocId(doc.id);
81
+
82
+ let mimeType = 'application/pdf';
83
+ if (doc.type === 'JPG' || doc.type === 'PNG') mimeType = 'image/jpeg';
84
+
85
+ const suggestions = await analyzeDocumentContent(doc.name, doc.category, boqItems, doc.content, mimeType);
86
+ onAnalyzeDocument(doc.id, suggestions);
87
+ setAnalyzingDocId(null);
88
+ };
89
+
90
+ const readFileAsBase64 = (file: File): Promise<string> => {
91
+ return new Promise((resolve, reject) => {
92
+ const reader = new FileReader();
93
+ reader.onload = () => {
94
+ const result = reader.result as string;
95
+ // Remove data URL prefix
96
+ const base64 = result.split(',')[1];
97
+ resolve(base64);
98
+ };
99
+ reader.onerror = reject;
100
+ reader.readAsDataURL(file);
101
+ });
102
+ };
103
+
104
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
105
+ if (e.target.files && e.target.files[0]) {
106
+ const file = e.target.files[0];
107
+ setSelectedFile(file);
108
+ setNewDocName(file.name);
109
+ setHasAiSuggested(false);
110
+
111
+ const ext = file.name.split('.').pop()?.toLowerCase();
112
+ if (ext === 'pdf') setNewDocType('PDF');
113
+ else if (['jpg', 'jpeg', 'png', 'gif'].includes(ext || '')) setNewDocType('JPG');
114
+ else if (['xlsx', 'xls', 'csv'].includes(ext || '')) setNewDocType('XLSX');
115
+ else if (['docx', 'doc'].includes(ext || '')) setNewDocType('DOCX');
116
+ else if (['dwg', 'dxf'].includes(ext || '')) setNewDocType('DWG');
117
+ else setNewDocType('PDF');
118
+
119
+ // AI Meta Suggestion
120
+ setIsSuggestingMetadata(true);
121
+ const suggestion = await suggestDocumentMetadata(file.name, "USER_ROLE"); // Pass role if available
122
+ setIsSuggestingMetadata(false);
123
+
124
+ if (suggestion) {
125
+ if (suggestion.category) setNewDocCategory(suggestion.category as DocumentCategory);
126
+ if (suggestion.module) setNewDocModule(suggestion.module as ModuleType);
127
+ setHasAiSuggested(true);
128
+ }
129
+ }
130
+ };
131
+
132
+ const handleUpload = async (e: React.FormEvent) => {
133
+ e.preventDefault();
134
+ setIsUploading(true);
135
+
136
+ // Simulate upload delay
137
+ await new Promise(resolve => setTimeout(resolve, 800));
138
+
139
+ let fileSize = '0.0 MB';
140
+ let fileUrl = undefined;
141
+ let base64Content: string | undefined = undefined;
142
+
143
+ if (selectedFile) {
144
+ fileSize = `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`;
145
+ fileUrl = URL.createObjectURL(selectedFile);
146
+ try {
147
+ base64Content = await readFileAsBase64(selectedFile);
148
+ } catch (err) {
149
+ console.error("Failed to read file", err);
150
+ }
151
+ } else {
152
+ fileSize = `${(Math.random() * 5 + 0.5).toFixed(1)} MB`;
153
+ }
154
+
155
+ const newDoc: ProjectDocument = {
156
+ id: `D${Date.now()}`,
157
+ name: newDocName,
158
+ type: newDocType,
159
+ category: newDocCategory,
160
+ module: newDocModule,
161
+ uploadDate: new Date().toISOString().split('T')[0],
162
+ size: fileSize,
163
+ url: fileUrl,
164
+ content: base64Content,
165
+ isAnalyzed: false,
166
+ tags: newDocTags.split(',').map(t => t.trim()).filter(t => t !== '')
167
+ };
168
+
169
+ onAddDocument(newDoc);
170
+
171
+ // Auto-Extraction for Bills
172
+ if (newDocCategory === 'BILL' && onBillUploaded) {
173
+ setIsAnalyzingBill(true);
174
+ try {
175
+ let mimeType = 'application/pdf';
176
+ if (newDocType === 'JPG' || newDocType === 'PNG') mimeType = 'image/jpeg';
177
+
178
+ const extracted = await extractBillData(newDocName, base64Content, mimeType);
179
+ if (extracted) {
180
+ onBillUploaded(extracted);
181
+ }
182
+ } catch (err) {
183
+ console.error("Bill extraction failed", err);
184
+ }
185
+ setIsAnalyzingBill(false);
186
+ }
187
+
188
+ setIsUploading(false);
189
+ setIsUploadModalOpen(false);
190
+ setNewDocName('');
191
+ setNewDocTags('');
192
+ setSelectedFile(null);
193
+ setHasAiSuggested(false);
194
+ };
195
+
196
+ const getIcon = (type: string) => {
197
+ if (type.includes('PDF')) return <FileText className="w-5 h-5 text-red-500" />;
198
+ if (type.includes('JPG') || type.includes('PNG')) return <Image className="w-5 h-5 text-blue-500" />;
199
+ if (type.includes('XLS')) return <FileSpreadsheet className="w-5 h-5 text-green-600" />;
200
+ if (type.includes('DOC')) return <FileText className="w-5 h-5 text-blue-600" />;
201
+ if (type.includes('DWG')) return <Paperclip className="w-5 h-5 text-slate-500" />;
202
+ return <File className="w-5 h-5 text-slate-400" />;
203
+ };
204
+
205
+ return (
206
+ <div className={`bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden ${compact ? '' : 'h-full flex flex-col'}`}>
207
+ <div className="px-6 py-4 border-b border-slate-200 flex flex-col md:flex-row md:items-center justify-between gap-4">
208
+ <div>
209
+ <h3 className="font-semibold text-slate-800">
210
+ {compact ? 'Related Documents' : 'Document Management'}
211
+ </h3>
212
+ {!compact && <p className="text-sm text-slate-500">Central repository for all project files</p>}
213
+ </div>
214
+ {allowUpload && (
215
+ <button
216
+ onClick={() => setIsUploadModalOpen(true)}
217
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors shadow-sm text-sm"
218
+ >
219
+ <UploadCloud className="w-4 h-4" />
220
+ Upload Document
221
+ </button>
222
+ )}
223
+ </div>
224
+
225
+ <div className="px-6 py-3 bg-slate-50 border-b border-slate-200 flex flex-col gap-3">
226
+ <div className="relative w-full">
227
+ <Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
228
+ <input
229
+ type="text"
230
+ placeholder="Search documents by name or tags..."
231
+ value={searchTerm}
232
+ onChange={(e) => setSearchTerm(e.target.value)}
233
+ className="w-full pl-9 pr-4 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
234
+ />
235
+ </div>
236
+
237
+ <div className="flex flex-wrap items-center gap-2">
238
+ <select
239
+ value={selectedCategory}
240
+ onChange={(e) => setSelectedCategory(e.target.value as any)}
241
+ className="px-3 py-2 text-sm border border-slate-300 rounded-lg bg-white focus:ring-2 focus:ring-blue-500 outline-none min-w-[140px]"
242
+ >
243
+ <option value="ALL">All Categories</option>
244
+ <option value="CONTRACT">Contracts</option>
245
+ <option value="DRAWING">Drawings</option>
246
+ <option value="PERMIT">Permits</option>
247
+ <option value="REPORT">Reports</option>
248
+ <option value="BILL">Bills</option>
249
+ </select>
250
+ </div>
251
+ </div>
252
+
253
+ <div className={`${compact ? 'max-h-[300px]' : 'flex-1'} overflow-y-auto`}>
254
+ {filteredDocs.length > 0 ? (
255
+ <table className="w-full text-left text-sm">
256
+ <thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200 sticky top-0">
257
+ <tr>
258
+ <th className="px-6 py-3">Name</th>
259
+ <th className="px-6 py-3 hidden md:table-cell">Category</th>
260
+ <th className="px-6 py-3 text-right">Date</th>
261
+ <th className="px-6 py-3 text-center">AI Scan</th>
262
+ <th className="px-6 py-3 text-center">Action</th>
263
+ </tr>
264
+ </thead>
265
+ <tbody className="divide-y divide-slate-100">
266
+ {filteredDocs.map((doc) => (
267
+ <tr key={doc.id} className="hover:bg-slate-50 transition-colors">
268
+ <td className="px-6 py-3">
269
+ <div className="flex flex-col gap-1">
270
+ <div className="flex items-center gap-3">
271
+ {getIcon(doc.type)}
272
+ <p className="font-medium text-slate-700">{doc.name}</p>
273
+ </div>
274
+ {doc.tags && doc.tags.length > 0 && (
275
+ <div className="flex flex-wrap gap-1 ml-8">
276
+ {doc.tags.map((tag, i) => (
277
+ <span key={i} className="flex items-center gap-0.5 px-1.5 py-0.5 bg-slate-100 text-slate-500 text-[10px] rounded-full border border-slate-200">
278
+ <Tag className="w-2 h-2" />
279
+ {tag}
280
+ </span>
281
+ ))}
282
+ </div>
283
+ )}
284
+ </div>
285
+ </td>
286
+ <td className="px-6 py-3 hidden md:table-cell">
287
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-600 border border-slate-200">
288
+ {doc.category}
289
+ </span>
290
+ </td>
291
+ <td className="px-6 py-3 text-right text-slate-500">{doc.uploadDate}</td>
292
+ <td className="px-6 py-3 text-center">
293
+ {analyzingDocId === doc.id ? (
294
+ <div className="flex items-center justify-center gap-1.5 text-indigo-600 font-bold text-xs animate-pulse">
295
+ <Loader2 className="w-3 h-3 animate-spin" />
296
+ Reading...
297
+ </div>
298
+ ) : doc.isAnalyzed ? (
299
+ <div className="flex items-center justify-center gap-1 text-emerald-600 font-bold text-xs">
300
+ <CheckCircle2 className="w-3.5 h-3.5" />
301
+ Synced
302
+ </div>
303
+ ) : (
304
+ <button
305
+ onClick={() => handleDeepScan(doc)}
306
+ className="inline-flex items-center gap-1 px-2 py-1 bg-indigo-50 text-indigo-600 border border-indigo-100 rounded text-[10px] font-bold hover:bg-indigo-100 transition-colors"
307
+ >
308
+ <Zap className="w-3 h-3 fill-current" />
309
+ AI Deep Scan
310
+ </button>
311
+ )}
312
+ </td>
313
+ <td className="px-6 py-3 text-center">
314
+ <a href={doc.url} download={doc.name} className="text-blue-600 hover:text-blue-800 p-1">
315
+ <Download className="w-4 h-4" />
316
+ </a>
317
+ </td>
318
+ </tr>
319
+ ))}
320
+ </tbody>
321
+ </table>
322
+ ) : (
323
+ <div className="flex flex-col items-center justify-center py-12 text-slate-400">
324
+ <File className="w-12 h-12 mb-3 opacity-20" />
325
+ <p>No documents found</p>
326
+ </div>
327
+ )}
328
+ </div>
329
+
330
+ {isUploadModalOpen && (
331
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
332
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden">
333
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center">
334
+ <div className="flex items-center gap-2">
335
+ <h3 className="font-semibold text-slate-800">Upload & Analyze</h3>
336
+ {isSuggestingMetadata && !isAnalyzingBill && (
337
+ <div className="flex items-center gap-1 px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded animate-pulse">
338
+ <Sparkles className="w-2.5 h-2.5" />
339
+ AI Analyzing Name...
340
+ </div>
341
+ )}
342
+ {isAnalyzingBill && (
343
+ <div className="flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-600 text-[10px] font-bold rounded animate-pulse">
344
+ <Loader2 className="w-2.5 h-2.5 animate-spin" />
345
+ Extracting Bill Data...
346
+ </div>
347
+ )}
348
+ </div>
349
+ <button onClick={() => !isUploading && setIsUploadModalOpen(false)} className="text-slate-400 hover:text-slate-600">
350
+ <X className="w-5 h-5" />
351
+ </button>
352
+ </div>
353
+
354
+ <form onSubmit={handleUpload} className="p-6 space-y-4">
355
+ <div>
356
+ <label className="block text-sm font-medium text-slate-700 mb-2">Select File</label>
357
+ <div className={`border-2 border-dashed rounded-xl p-6 text-center transition-colors relative group ${selectedFile ? 'border-emerald-400 bg-emerald-50' : 'border-slate-300 hover:bg-slate-50'}`}>
358
+ <input type="file" onChange={handleFileChange} disabled={isUploading} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" />
359
+ <div className="flex flex-col items-center gap-2">
360
+ <div className={`p-3 rounded-full ${selectedFile ? 'bg-emerald-100 text-emerald-600' : 'bg-blue-50 text-blue-600'}`}>
361
+ {selectedFile ? <CheckCircle2 className="w-6 h-6" /> : <UploadCloud className="w-6 h-6" />}
362
+ </div>
363
+ <p className="text-sm font-medium">{selectedFile ? selectedFile.name : "Choose file"}</p>
364
+ </div>
365
+ </div>
366
+ </div>
367
+
368
+ <div className="grid grid-cols-2 gap-4">
369
+ <div className="space-y-1">
370
+ <div className="flex items-center justify-between">
371
+ <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight">Category</label>
372
+ {hasAiSuggested && (
373
+ <span className="text-[9px] font-bold text-indigo-500 flex items-center gap-0.5">
374
+ <Sparkles className="w-2 h-2" /> AI Sug
375
+ </span>
376
+ )}
377
+ </div>
378
+ <select
379
+ value={newDocCategory}
380
+ onChange={(e) => setNewDocCategory(e.target.value as any)}
381
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all bg-white ${isSuggestingMetadata ? 'opacity-50 blur-[1px]' : ''} ${hasAiSuggested ? 'border-indigo-200' : 'border-slate-300'}`}
382
+ >
383
+ <option value="REPORT">Progress Report</option>
384
+ <option value="BILL">Invoice / Bill</option>
385
+ <option value="DRAWING">Technical Drawing</option>
386
+ <option value="CONTRACT">Contract</option>
387
+ <option value="PERMIT">Permit</option>
388
+ <option value="OTHER">Other</option>
389
+ </select>
390
+ </div>
391
+
392
+ <div className="space-y-1">
393
+ <div className="flex items-center justify-between">
394
+ <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight">Module</label>
395
+ {hasAiSuggested && (
396
+ <span className="text-[9px] font-bold text-indigo-500 flex items-center gap-0.5">
397
+ <Sparkles className="w-2 h-2" /> AI Sug
398
+ </span>
399
+ )}
400
+ </div>
401
+ <select
402
+ value={newDocModule}
403
+ onChange={(e) => setNewDocModule(e.target.value as any)}
404
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all bg-white ${isSuggestingMetadata ? 'opacity-50 blur-[1px]' : ''} ${hasAiSuggested ? 'border-indigo-200' : 'border-slate-300'}`}
405
+ >
406
+ <option value="GENERAL">General</option>
407
+ <option value="SITE">Site Ops</option>
408
+ <option value="FINANCE">Financials</option>
409
+ <option value="MASTER">Master Records</option>
410
+ <option value="LIABILITY">Liabilities</option>
411
+ </select>
412
+ </div>
413
+ </div>
414
+
415
+ <div className="space-y-1">
416
+ <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight">Tags (comma separated)</label>
417
+ <div className="relative">
418
+ <Tag className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
419
+ <input
420
+ type="text"
421
+ placeholder="e.g. foundation, structural, approved"
422
+ value={newDocTags}
423
+ onChange={(e) => setNewDocTags(e.target.value)}
424
+ className="w-full pl-9 pr-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
425
+ />
426
+ </div>
427
+ </div>
428
+
429
+ <div className="pt-4 flex justify-end gap-3 border-t border-slate-100">
430
+ <button type="button" onClick={() => setIsUploadModalOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded-lg transition-colors">Cancel</button>
431
+ <button type="submit" disabled={isUploading || isSuggestingMetadata || isAnalyzingBill} className="px-6 py-2 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md active:scale-95 transition-all disabled:opacity-50 disabled:active:scale-100">
432
+ {isAnalyzingBill ? 'Extracting Data...' : (isUploading ? 'Uploading...' : 'Save Document')}
433
+ </button>
434
+ </div>
435
+ </form>
436
+ </div>
437
+ </div>
438
+ )}
439
+ </div>
440
+ );
441
+ };
442
+
443
+ export default DocumentManager;
components/EquipmentManager.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Equipment } from '../types';
4
+ import { Truck, Settings, AlertTriangle, CheckCircle2, Plus, Search, Filter } from 'lucide-react';
5
+
6
+ interface EquipmentManagerProps {
7
+ equipment: Equipment[];
8
+ }
9
+
10
+ const EquipmentManager: React.FC<EquipmentManagerProps> = ({ equipment }) => {
11
+ const [searchQuery, setSearchQuery] = React.useState('');
12
+
13
+ const filteredEquipment = equipment.filter(e =>
14
+ e.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
15
+ e.type.toLowerCase().includes(searchQuery.toLowerCase())
16
+ );
17
+
18
+ const getStatusColor = (status: string) => {
19
+ switch (status) {
20
+ case 'OPERATIONAL': return 'text-emerald-600 bg-emerald-50 border-emerald-100';
21
+ case 'MAINTENANCE': return 'text-amber-600 bg-amber-50 border-amber-100';
22
+ case 'OUT_OF_ORDER': return 'text-red-600 bg-red-50 border-red-100';
23
+ default: return 'text-slate-600 bg-slate-50 border-slate-100';
24
+ }
25
+ };
26
+
27
+ return (
28
+ <div className="space-y-6">
29
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
30
+ <div className="flex items-center gap-4 flex-1 max-w-md">
31
+ <div className="relative flex-1">
32
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
33
+ <input
34
+ type="text"
35
+ placeholder="Search equipment..."
36
+ value={searchQuery}
37
+ onChange={(e) => setSearchQuery(e.target.value)}
38
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
39
+ />
40
+ </div>
41
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
42
+ <Filter className="w-5 h-5" />
43
+ </button>
44
+ </div>
45
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
46
+ <Plus className="w-4 h-4" />
47
+ Add Equipment
48
+ </button>
49
+ </div>
50
+
51
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
52
+ {filteredEquipment.length === 0 ? (
53
+ <div className="col-span-full py-20 text-center bg-white rounded-2xl border border-dashed border-slate-300">
54
+ <Truck className="w-12 h-12 text-slate-300 mx-auto mb-4" />
55
+ <p className="text-slate-500 font-medium">No equipment found</p>
56
+ </div>
57
+ ) : (
58
+ filteredEquipment.map(item => (
59
+ <div key={item.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group">
60
+ <div className="p-6 border-b border-slate-100">
61
+ <div className="flex items-start justify-between mb-4">
62
+ <div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-xl flex items-center justify-center">
63
+ <Truck className="w-6 h-6" />
64
+ </div>
65
+ <span className={`px-2 py-1 rounded-full border text-[10px] font-bold uppercase tracking-wider ${getStatusColor(item.status)}`}>
66
+ {item.status.replace('_', ' ')}
67
+ </span>
68
+ </div>
69
+ <h3 className="font-bold text-slate-800 text-lg mb-1">{item.name}</h3>
70
+ <p className="text-xs text-slate-500 font-medium">{item.type}</p>
71
+ </div>
72
+
73
+ <div className="p-6 space-y-4">
74
+ <div className="grid grid-cols-2 gap-4">
75
+ <div className="space-y-1">
76
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Last Maint.</span>
77
+ <p className="text-xs font-bold text-slate-700">{item.lastMaintenance}</p>
78
+ </div>
79
+ <div className="space-y-1">
80
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Next Maint.</span>
81
+ <p className="text-xs font-bold text-blue-600">{item.nextMaintenance}</p>
82
+ </div>
83
+ </div>
84
+
85
+ <div className="p-3 bg-slate-50 rounded-xl flex items-center justify-between">
86
+ <div className="flex items-center gap-2">
87
+ <Settings className="w-4 h-4 text-slate-400" />
88
+ <span className="text-xs font-medium text-slate-600">Hourly Rate</span>
89
+ </div>
90
+ <span className="text-sm font-bold text-slate-800">৳{item.hourlyRate}/hr</span>
91
+ </div>
92
+
93
+ <div className="flex items-center justify-between pt-2">
94
+ <div className="flex items-center gap-2">
95
+ <div className="w-6 h-6 bg-slate-200 rounded-full flex items-center justify-center text-[10px] font-bold text-slate-600">
96
+ {item.assignedOperator?.charAt(0) || '?'}
97
+ </div>
98
+ <span className="text-xs text-slate-500">{item.assignedOperator || 'No Operator'}</span>
99
+ </div>
100
+ <button className="text-xs font-bold text-blue-600 hover:text-blue-700">Manage</button>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ ))
105
+ )}
106
+ </div>
107
+ </div>
108
+ );
109
+ };
110
+
111
+ export default EquipmentManager;
components/FinancialAnalytics.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { BOQItem, Bill } from '../types';
4
+ import {
5
+ BarChart,
6
+ Bar,
7
+ XAxis,
8
+ YAxis,
9
+ CartesianGrid,
10
+ Tooltip,
11
+ Legend,
12
+ ResponsiveContainer,
13
+ PieChart,
14
+ Pie,
15
+ Cell,
16
+ LineChart,
17
+ Line
18
+ } from 'recharts';
19
+ import { TrendingUp, TrendingDown, DollarSign, PieChart as PieChartIcon, BarChart3 } from 'lucide-react';
20
+
21
+ interface FinancialAnalyticsProps {
22
+ boq: BOQItem[];
23
+ bills: Bill[];
24
+ }
25
+
26
+ const FinancialAnalytics: React.FC<FinancialAnalyticsProps> = ({ boq, bills }) => {
27
+ const totalBudget = boq.reduce((acc, item) => acc + (item.plannedQty * item.plannedUnitCost), 0);
28
+ const totalActual = bills.reduce((acc, bill) => acc + bill.amount, 0);
29
+ const totalContract = boq.reduce((acc, item) => acc + (item.plannedQty * item.rate), 0);
30
+
31
+ const budgetVsActualData = boq.slice(0, 5).map(item => ({
32
+ name: item.description.substring(0, 15) + '...',
33
+ Budget: item.plannedQty * item.plannedUnitCost,
34
+ Actual: (item.executedQty / item.plannedQty) * (item.plannedQty * item.plannedUnitCost) * 1.1 // Simulated actual
35
+ }));
36
+
37
+ const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
38
+
39
+ const expenseByCategory = [
40
+ { name: 'Material', value: bills.filter(b => b.category === 'MATERIAL').reduce((a, b) => a + b.amount, 0) || 450000 },
41
+ { name: 'Labor', value: bills.filter(b => b.category === 'LABOR').reduce((a, b) => a + b.amount, 0) || 280000 },
42
+ { name: 'Equipment', value: bills.filter(b => b.category === 'EQUIPMENT').reduce((a, b) => a + b.amount, 0) || 120000 },
43
+ { name: 'Overhead', value: bills.filter(b => b.category === 'OVERHEAD').reduce((a, b) => a + b.amount, 0) || 85000 },
44
+ ];
45
+
46
+ return (
47
+ <div className="space-y-6">
48
+ {/* Top Stats */}
49
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
50
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
51
+ <div className="flex items-center justify-between mb-4">
52
+ <div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
53
+ <DollarSign className="w-5 h-5 text-blue-600" />
54
+ </div>
55
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Total Budget</span>
56
+ </div>
57
+ <h3 className="text-2xl font-bold text-slate-800">৳{(totalBudget / 1000000).toFixed(2)}M</h3>
58
+ <p className="text-xs text-slate-500 mt-1">Internal estimated cost</p>
59
+ </div>
60
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
61
+ <div className="flex items-center justify-between mb-4">
62
+ <div className="w-10 h-10 bg-emerald-50 rounded-lg flex items-center justify-center">
63
+ <TrendingUp className="w-5 h-5 text-emerald-600" />
64
+ </div>
65
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Actual Spent</span>
66
+ </div>
67
+ <h3 className="text-2xl font-bold text-slate-800">৳{(totalActual / 1000000).toFixed(2)}M</h3>
68
+ <p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
69
+ <TrendingUp className="w-3 h-3" />
70
+ {((totalActual / totalBudget) * 100).toFixed(1)}% of budget used
71
+ </p>
72
+ </div>
73
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
74
+ <div className="flex items-center justify-between mb-4">
75
+ <div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
76
+ <BarChart3 className="w-5 h-5 text-amber-600" />
77
+ </div>
78
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Project Margin</span>
79
+ </div>
80
+ <h3 className="text-2xl font-bold text-slate-800">৳{((totalContract - totalActual) / 1000000).toFixed(2)}M</h3>
81
+ <p className="text-xs text-amber-600 mt-1">Estimated gross profit</p>
82
+ </div>
83
+ </div>
84
+
85
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
86
+ {/* Budget vs Actual Chart */}
87
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
88
+ <h3 className="font-bold text-slate-800 mb-6 flex items-center gap-2">
89
+ <BarChart3 className="w-5 h-5 text-blue-600" />
90
+ Budget vs Actual (Top Items)
91
+ </h3>
92
+ <div className="h-[300px]">
93
+ <ResponsiveContainer width="100%" height="100%">
94
+ <BarChart data={budgetVsActualData}>
95
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
96
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} />
97
+ <YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} />
98
+ <Tooltip
99
+ contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
100
+ cursor={{ fill: '#f8fafc' }}
101
+ />
102
+ <Legend iconType="circle" />
103
+ <Bar dataKey="Budget" fill="#cbd5e1" radius={[4, 4, 0, 0]} />
104
+ <Bar dataKey="Actual" fill="#3b82f6" radius={[4, 4, 0, 0]} />
105
+ </BarChart>
106
+ </ResponsiveContainer>
107
+ </div>
108
+ </div>
109
+
110
+ {/* Expense Distribution */}
111
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
112
+ <h3 className="font-bold text-slate-800 mb-6 flex items-center gap-2">
113
+ <PieChartIcon className="w-5 h-5 text-blue-600" />
114
+ Expense Distribution
115
+ </h3>
116
+ <div className="h-[300px] flex items-center">
117
+ <ResponsiveContainer width="100%" height="100%">
118
+ <PieChart>
119
+ <Pie
120
+ data={expenseByCategory}
121
+ cx="50%"
122
+ cy="50%"
123
+ innerRadius={60}
124
+ outerRadius={80}
125
+ paddingAngle={5}
126
+ dataKey="value"
127
+ >
128
+ {expenseByCategory.map((entry, index) => (
129
+ <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
130
+ ))}
131
+ </Pie>
132
+ <Tooltip />
133
+ <Legend verticalAlign="bottom" height={36} />
134
+ </PieChart>
135
+ </ResponsiveContainer>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ );
141
+ };
142
+
143
+ export default FinancialAnalytics;
components/FinancialControl.tsx ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useMemo } from 'react';
2
+ import { ProjectState, ProjectDocument, BOQItem, UserRole, Bill, ExtractedBill } from '../types';
3
+ import { Download, PlusCircle, CheckCircle2, ChevronDown, ChevronUp, TrendingUp, Wallet, ArrowDownRight, Sparkles, Loader2, Zap, Package, X, Save, Edit2, Hammer, UsersRound, AlertOctagon, FileText } from 'lucide-react';
4
+ import DocumentManager from './DocumentManager';
5
+ import { extractBillData, suggestActualCostBreakdown, parseRunningBillDetails } from '../services/localAnalysisService';
6
+ import jsPDF from 'jspdf';
7
+ import autoTable from 'jspdf-autotable';
8
+
9
+ interface FinancialControlProps {
10
+ data: ProjectState;
11
+ onAddDocument: (doc: ProjectDocument) => void;
12
+ onUpdateBOQItem?: (itemId: string, updatedItem: Partial<BOQItem>) => void;
13
+ onAddBill: (bill: Bill) => void;
14
+ onUpdatePDRemarks: (type: 'BILL', id: string, remarks: string) => void;
15
+ onBillItemizedUpdate: (items: { boqId: string; amount: number }[]) => void;
16
+ userRole: UserRole;
17
+ }
18
+
19
+ const FinancialControl: React.FC<FinancialControlProps> = ({ data, onAddDocument, onUpdateBOQItem, onAddBill, onUpdatePDRemarks, onBillItemizedUpdate, userRole }) => {
20
+ const [expandedRow, setExpandedRow] = useState<string | null>(null);
21
+ const [isAiLoading, setIsAiLoading] = useState(false);
22
+ const [analyzingItemId, setAnalyzingItemId] = useState<string | null>(null);
23
+ const [isBillModalOpen, setIsBillModalOpen] = useState(false);
24
+ const [editingRemarksId, setEditingRemarksId] = useState<string | null>(null);
25
+ const [tempRemarks, setTempRemarks] = useState('');
26
+
27
+ // Bill Form
28
+ const [billType, setBillType] = useState<Bill['type']>('VENDOR_INVOICE');
29
+ const [billCategory, setBillCategory] = useState<NonNullable<Bill['category']>>('OTHER');
30
+ const [billEntity, setBillEntity] = useState('');
31
+ const [billAmount, setBillAmount] = useState('');
32
+ const [billDate, setBillDate] = useState(new Date().toISOString().split('T')[0]);
33
+ const [aiAutofilled, setAiAutofilled] = useState(false);
34
+ const [detectedBillDocName, setDetectedBillDocName] = useState('');
35
+
36
+ const canAddClientBill = userRole === 'MANAGER' || userRole === 'DIRECTOR';
37
+ const canAddVendorBill = userRole === 'ACCOUNTANT' || userRole === 'DIRECTOR';
38
+ const canUploadDoc = canAddClientBill || canAddVendorBill;
39
+ const isDirector = userRole === 'DIRECTOR';
40
+
41
+ const clientBills = data.bills.filter(b => b.type === 'CLIENT_RA');
42
+ const vendorBills = data.bills.filter(b => b.type === 'VENDOR_INVOICE' || b.type === 'MATERIAL_EXPENSE' || b.type === 'SUB_CONTRACTOR');
43
+
44
+ const totalRevenue = clientBills.reduce((acc, b) => acc + b.amount, 0);
45
+ const totalExpenses = vendorBills.reduce((acc, b) => acc + b.amount, 0);
46
+
47
+ // Material Value Calculation
48
+ const materialInventoryValue = data.materials.reduce((sum, mat) => sum + (mat.currentStock * mat.averageRate), 0);
49
+
50
+ // --- LIVE SYNC CALCULATIONS ---
51
+
52
+ // 1. Live Material Stats (Automated from DPRs)
53
+ const materialStats = useMemo(() => {
54
+ return data.materials.map(m => {
55
+ const consumedQty = data.dprs.reduce((acc, dpr) => {
56
+ const usage = dpr.materialsUsed?.find(u => u.materialId === m.id);
57
+ return acc + (usage ? usage.qty : 0);
58
+ }, 0);
59
+ return {
60
+ ...m,
61
+ liveConsumedQty: consumedQty,
62
+ liveExpense: consumedQty * m.averageRate
63
+ };
64
+ }).filter(m => m.liveConsumedQty > 0 || m.totalConsumed > 0);
65
+ }, [data.materials, data.dprs]);
66
+
67
+ // 2. Live Sub-Contractor Stats (Automated from DPRs vs Bills)
68
+ const subContractorStats = useMemo(() => {
69
+ if (!data.subContractors) return [];
70
+ return data.subContractors.map(sc => {
71
+ // Calculate accrued liability directly from DPRs for perfect sync
72
+ const liveWorkValue = data.dprs
73
+ .filter(d => d.subContractorId === sc.id && d.workDoneQty && d.linkedBoqId)
74
+ .reduce((sum, d) => {
75
+ const rate = sc.agreedRates.find(r => r.boqId === d.linkedBoqId)?.rate || 0;
76
+ return sum + (d.workDoneQty! * rate);
77
+ }, 0);
78
+
79
+ return {
80
+ ...sc,
81
+ liveWorkValue,
82
+ balance: liveWorkValue - sc.totalBilled
83
+ };
84
+ });
85
+ }, [data.subContractors, data.dprs]);
86
+
87
+ // 3. Live BOQ Item Costing
88
+ const liveItemStats = useMemo(() => {
89
+ return data.boq.map(item => {
90
+ const relevantDPRs = data.dprs.filter(d => d.linkedBoqId === item.id);
91
+
92
+ // Calculate Material Expense
93
+ let materialExp = 0;
94
+ relevantDPRs.forEach(dpr => {
95
+ dpr.materialsUsed?.forEach(usage => {
96
+ const mat = data.materials.find(m => m.id === usage.materialId);
97
+ if (mat) {
98
+ materialExp += usage.qty * mat.averageRate;
99
+ }
100
+ });
101
+ });
102
+
103
+ // Calculate Sub-Contractor Liability
104
+ let subContractExp = 0;
105
+ relevantDPRs.forEach(dpr => {
106
+ if (dpr.subContractorId && dpr.workDoneQty) {
107
+ const sc = data.subContractors.find(s => s.id === dpr.subContractorId);
108
+ const rateObj = sc?.agreedRates.find(r => r.boqId === item.id);
109
+ if (rateObj) {
110
+ subContractExp += dpr.workDoneQty * rateObj.rate;
111
+ }
112
+ }
113
+ });
114
+
115
+ // Estimate Direct Labor
116
+ let directLaborExp = 0;
117
+ const avgDailyWage = 800;
118
+ const dprsWithNoSC = relevantDPRs.filter(d => !d.subContractorId);
119
+ const totalLaborDays = dprsWithNoSC.reduce((acc, d) => acc + d.laborCount, 0);
120
+ directLaborExp = totalLaborDays * avgDailyWage;
121
+
122
+ const totalActualCost = materialExp + subContractExp + directLaborExp;
123
+ const revenue = (item.billedAmount || 0);
124
+ const workDoneValue = item.executedQty * item.rate;
125
+ const profit = revenue - totalActualCost;
126
+
127
+ return {
128
+ ...item,
129
+ stats: {
130
+ materialExp,
131
+ subContractExp,
132
+ directLaborExp,
133
+ totalActualCost,
134
+ profit
135
+ }
136
+ };
137
+ });
138
+ }, [data.boq, data.dprs, data.materials, data.subContractors]);
139
+
140
+ const totalOperationalProfit = liveItemStats.reduce((acc, item) => {
141
+ const workValue = item.executedQty * item.rate;
142
+ return acc + (workValue - item.stats.totalActualCost);
143
+ }, 0);
144
+
145
+ const generateRABill = () => {
146
+ const doc = new jsPDF();
147
+
148
+ // Header
149
+ doc.setFontSize(20);
150
+ doc.text('Running Account (RA) Bill', 14, 22);
151
+
152
+ doc.setFontSize(10);
153
+ doc.text(`Project: ${data.name}`, 14, 30);
154
+ doc.text(`Date: ${new Date().toLocaleDateString()}`, 14, 35);
155
+ doc.text(`Generated By: BuildTrack AI`, 14, 40);
156
+
157
+ // Table Data
158
+ const tableData = data.boq
159
+ .filter(item => item.executedQty > 0)
160
+ .map((item, index) => [
161
+ index + 1,
162
+ item.description,
163
+ item.unit,
164
+ item.plannedQty.toLocaleString(),
165
+ item.executedQty.toLocaleString(),
166
+ `৳${item.rate.toLocaleString()}`,
167
+ `৳${(item.executedQty * item.rate).toLocaleString()}`
168
+ ]);
169
+
170
+ const totalBillValue = data.boq.reduce((sum, item) => sum + (item.executedQty * item.rate), 0);
171
+ const previouslyBilled = data.boq.reduce((sum, item) => sum + (item.billedAmount || 0), 0);
172
+ const netPayable = totalBillValue - previouslyBilled;
173
+
174
+ autoTable(doc, {
175
+ startY: 50,
176
+ head: [['Sr.', 'Description', 'Unit', 'Total Qty', 'Executed Qty', 'Rate', 'Amount']],
177
+ body: tableData,
178
+ theme: 'grid',
179
+ styles: { fontSize: 8 },
180
+ headStyles: { fillColor: [15, 23, 42] } // Slate-900
181
+ });
182
+
183
+ const finalY = (doc as any).lastAutoTable.finalY || 50;
184
+
185
+ doc.setFontSize(10);
186
+ doc.text(`Total Value of Work Executed: ৳${totalBillValue.toLocaleString()}`, 14, finalY + 10);
187
+ doc.text(`Less: Previously Billed: ৳${previouslyBilled.toLocaleString()}`, 14, finalY + 16);
188
+ doc.setFontSize(12);
189
+ doc.setFont("helvetica", "bold");
190
+ doc.text(`Net Amount Payable: ৳${netPayable.toLocaleString()}`, 14, finalY + 24);
191
+
192
+ doc.save(`RA_Bill_${data.id}_${new Date().toISOString().split('T')[0]}.pdf`);
193
+ };
194
+
195
+ const toggleRow = (id: string) => {
196
+ setExpandedRow(expandedRow === id ? null : id);
197
+ };
198
+
199
+ const handleBillUploaded = (extracted: ExtractedBill) => {
200
+ setAiAutofilled(true);
201
+ const latestBill = data.documents.find(d => d.category === 'BILL' && d.uploadDate === new Date().toISOString().split('T')[0]);
202
+ if (latestBill) setDetectedBillDocName(latestBill.name);
203
+
204
+ if (extracted.type) setBillType(extracted.type === 'CLIENT_RA' ? 'CLIENT_RA' : 'VENDOR_INVOICE');
205
+ if (extracted.entityName) setBillEntity(extracted.entityName);
206
+ if (extracted.amount) setBillAmount(extracted.amount.toString());
207
+ if (extracted.date) setBillDate(extracted.date);
208
+
209
+ setIsBillModalOpen(true);
210
+ setTimeout(() => setAiAutofilled(false), 5000);
211
+ };
212
+
213
+ const handleAiBillExtraction = async () => {
214
+ const lastBillDoc = data.documents.find(d => d.category === 'BILL');
215
+ if (!lastBillDoc) {
216
+ alert("No bill documents found to analyze.");
217
+ return;
218
+ }
219
+ setIsAiLoading(true);
220
+ const extracted = await extractBillData(lastBillDoc.name);
221
+ setDetectedBillDocName(lastBillDoc.name);
222
+ setIsAiLoading(false);
223
+ if (extracted) {
224
+ alert(`AI Extracted Bill Info:\n\nEntity: ${extracted.entityName}\nAmount: ৳${extracted.amount}\nType: ${extracted.type}\n\nYou can now use these values to populate the form.`);
225
+ handleBillUploaded(extracted);
226
+ }
227
+ };
228
+
229
+ const handleCreateBill = async (e: React.FormEvent) => {
230
+ e.preventDefault();
231
+ onAddBill({
232
+ id: `BILL-${Date.now()}`,
233
+ type: billType,
234
+ entityName: billEntity,
235
+ amount: Number(billAmount),
236
+ date: billDate,
237
+ status: 'PENDING',
238
+ category: billType === 'CLIENT_RA' ? undefined : billCategory
239
+ });
240
+
241
+ if (billType === 'CLIENT_RA' && detectedBillDocName) {
242
+ const confirmItemize = window.confirm("Do you want to automatically distribute this bill amount to BOQ items based on the uploaded document?");
243
+ if (confirmItemize) {
244
+ setIsAiLoading(true);
245
+ const itemizedUpdates = await parseRunningBillDetails(detectedBillDocName, data.boq);
246
+ onBillItemizedUpdate(itemizedUpdates);
247
+ setIsAiLoading(false);
248
+ alert(`Successfully mapped bill amount to ${itemizedUpdates.length} BOQ items.`);
249
+ }
250
+ }
251
+
252
+ setIsBillModalOpen(false);
253
+ setBillEntity('');
254
+ setBillAmount('');
255
+ setAiAutofilled(false);
256
+ setDetectedBillDocName('');
257
+ setBillCategory('OTHER');
258
+ };
259
+
260
+ const handleBillTypeChange = (newType: Bill['type']) => {
261
+ setBillType(newType);
262
+ if (newType === 'MATERIAL_EXPENSE') setBillCategory('MATERIAL');
263
+ else if (newType === 'SUB_CONTRACTOR') setBillCategory('LABOR');
264
+ else setBillCategory('OTHER');
265
+ };
266
+
267
+ const saveRemarks = (id: string) => {
268
+ onUpdatePDRemarks('BILL', id, tempRemarks);
269
+ setEditingRemarksId(null);
270
+ };
271
+
272
+ const BillTable = ({ bills, title }: { bills: typeof data.bills, title: string }) => (
273
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col h-full">
274
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
275
+ <h3 className="font-semibold text-slate-800">{title}</h3>
276
+ <button className="p-2 text-slate-400 hover:text-slate-600">
277
+ <Download className="w-4 h-4" />
278
+ </button>
279
+ </div>
280
+ <div className="overflow-x-auto flex-1">
281
+ <table className="w-full text-left text-sm">
282
+ <thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
283
+ <tr>
284
+ <th className="px-6 py-3 whitespace-nowrap">Bill ID</th>
285
+ <th className="px-6 py-3 whitespace-nowrap">Entity / Description</th>
286
+ <th className="px-6 py-3 whitespace-nowrap">Date</th>
287
+ <th className="px-6 py-3 text-right whitespace-nowrap">Amount</th>
288
+ <th className="px-6 py-3 text-center whitespace-nowrap">Status</th>
289
+ <th className="px-6 py-3 whitespace-nowrap">Notes</th>
290
+ </tr>
291
+ </thead>
292
+ <tbody className="divide-y divide-slate-100">
293
+ {bills.map(bill => (
294
+ <tr key={bill.id} className="hover:bg-slate-50 transition-colors">
295
+ <td className="px-6 py-3 font-medium text-slate-700">
296
+ {bill.id}
297
+ {bill.type === 'SUB_CONTRACTOR' && <div className="text-[10px] text-orange-600 font-bold">Sub-Contract</div>}
298
+ {bill.type === 'MATERIAL_EXPENSE' && <div className="text-[10px] text-indigo-600 font-bold">Material</div>}
299
+ {bill.category && bill.category !== 'OTHER' && (
300
+ <div className="text-[10px] text-slate-500 font-medium bg-slate-100 px-1 rounded inline-block mt-0.5 border border-slate-200 ml-1">
301
+ {bill.category}
302
+ </div>
303
+ )}
304
+ </td>
305
+ <td className="px-6 py-3 text-slate-600 truncate max-w-[200px]">{bill.entityName}</td>
306
+ <td className="px-6 py-3 text-slate-500">{bill.date}</td>
307
+ <td className="px-6 py-3 text-right font-medium text-slate-900">৳{bill.amount.toLocaleString()}</td>
308
+ <td className="px-6 py-3 text-center">
309
+ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${
310
+ bill.status === 'PAID'
311
+ ? 'bg-emerald-50 text-emerald-700 border-emerald-200'
312
+ : 'bg-amber-50 text-amber-700 border-amber-200'
313
+ }`}>
314
+ {bill.status}
315
+ </span>
316
+ </td>
317
+ <td className="px-6 py-3 min-w-[200px]">
318
+ {editingRemarksId === bill.id ? (
319
+ <div className="flex items-center gap-1">
320
+ <input
321
+ type="text"
322
+ value={tempRemarks}
323
+ onChange={(e) => setTempRemarks(e.target.value)}
324
+ className="w-full text-xs border border-blue-300 rounded px-1 py-0.5 outline-none"
325
+ autoFocus
326
+ />
327
+ <button onClick={() => saveRemarks(bill.id)} className="text-emerald-600"><Save className="w-3.5 h-3.5"/></button>
328
+ <button onClick={() => setEditingRemarksId(null)} className="text-red-500"><X className="w-3.5 h-3.5"/></button>
329
+ </div>
330
+ ) : (
331
+ <div className="flex items-center gap-2 group/remark">
332
+ <span className="text-xs text-slate-500 italic truncate max-w-[150px]">
333
+ {bill.pdRemarks || (isDirector ? "Add note..." : "")}
334
+ </span>
335
+ {isDirector && (
336
+ <button
337
+ onClick={() => { setEditingRemarksId(bill.id); setTempRemarks(bill.pdRemarks || ''); }}
338
+ className="opacity-0 group-hover/remark:opacity-100 text-slate-400 hover:text-blue-600 transition-opacity"
339
+ >
340
+ <Edit2 className="w-3 h-3" />
341
+ </button>
342
+ )}
343
+ </div>
344
+ )}
345
+ </td>
346
+ </tr>
347
+ ))}
348
+ </tbody>
349
+ </table>
350
+ </div>
351
+ </div>
352
+ );
353
+
354
+ return (
355
+ <div className="space-y-6">
356
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
357
+ <div>
358
+ <h1 className="text-2xl font-bold text-slate-800">Financial Control</h1>
359
+ <p className="text-slate-500">Track Bills, Costs, and Profitability</p>
360
+ </div>
361
+ <div className="flex gap-2">
362
+ <button
363
+ onClick={handleAiBillExtraction}
364
+ disabled={isAiLoading}
365
+ className="flex items-center gap-2 bg-indigo-50 border border-indigo-200 text-indigo-600 px-4 py-2 rounded-lg hover:bg-indigo-100 transition-colors shadow-sm text-sm font-bold"
366
+ >
367
+ {isAiLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
368
+ Auto-Scan Bill
369
+ </button>
370
+ {canAddVendorBill && (
371
+ <button
372
+ onClick={() => { setIsBillModalOpen(true); setBillType('VENDOR_INVOICE'); setBillCategory('OTHER'); setBillEntity(''); setBillAmount(''); setAiAutofilled(false); }}
373
+ className="flex items-center gap-2 bg-white border border-slate-300 text-slate-700 px-4 py-2 rounded-lg hover:bg-slate-50 transition-colors shadow-sm text-sm font-medium"
374
+ >
375
+ <PlusCircle className="w-4 h-4" />
376
+ Add Expense / Bill
377
+ </button>
378
+ )}
379
+ {canAddClientBill && (
380
+ <button
381
+ onClick={() => { setIsBillModalOpen(true); setBillType('CLIENT_RA'); setBillEntity(''); setBillAmount(''); setAiAutofilled(false); }}
382
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors shadow-sm text-sm font-medium"
383
+ >
384
+ <PlusCircle className="w-4 h-4" />
385
+ Record Bill Received (PE)
386
+ </button>
387
+ )}
388
+ {canAddClientBill && (
389
+ <button
390
+ onClick={generateRABill}
391
+ className="flex items-center gap-2 bg-slate-800 text-white px-4 py-2 rounded-lg hover:bg-slate-900 transition-colors shadow-sm text-sm font-medium"
392
+ >
393
+ <FileText className="w-4 h-4" />
394
+ Generate RA Bill (PDF)
395
+ </button>
396
+ )}
397
+ </div>
398
+ </div>
399
+
400
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
401
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
402
+ <div className="flex justify-between items-start mb-2">
403
+ <div className="p-2 bg-blue-50 rounded-lg text-blue-600">
404
+ <Wallet className="w-5 h-5" />
405
+ </div>
406
+ </div>
407
+ <p className="text-sm font-semibold text-slate-500 uppercase tracking-wide">Total Revenue</p>
408
+ <h2 className="text-2xl font-bold text-slate-800 mt-1">৳{totalRevenue.toLocaleString()}</h2>
409
+ <p className="text-xs text-slate-400 mt-1">Total Billed to Client</p>
410
+ </div>
411
+
412
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
413
+ <div className="flex justify-between items-start mb-2">
414
+ <div className="p-2 bg-red-50 rounded-lg text-red-600">
415
+ <ArrowDownRight className="w-5 h-5" />
416
+ </div>
417
+ </div>
418
+ <p className="text-sm font-semibold text-slate-500 uppercase tracking-wide">Total Expenses</p>
419
+ <h2 className="text-2xl font-bold text-slate-800 mt-1">৳{totalExpenses.toLocaleString()}</h2>
420
+ <p className="text-xs text-slate-400 mt-1">Vendor + Sub-contract + Materials</p>
421
+ </div>
422
+
423
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
424
+ <div className="flex justify-between items-start mb-2">
425
+ <div className="p-2 bg-indigo-50 rounded-lg text-indigo-600">
426
+ <Package className="w-5 h-5" />
427
+ </div>
428
+ </div>
429
+ <p className="text-sm font-semibold text-slate-500 uppercase tracking-wide">Material Inventory Value</p>
430
+ <h2 className="text-2xl font-bold text-slate-800 mt-1">৳{materialInventoryValue.toLocaleString()}</h2>
431
+ <p className="text-xs text-slate-400 mt-1">Asset Value in Stock</p>
432
+ </div>
433
+
434
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden border-l-4 border-l-violet-500">
435
+ <div className="flex justify-between items-start mb-2">
436
+ <div className="p-2 bg-violet-50 rounded-lg text-violet-600">
437
+ <TrendingUp className="w-5 h-5" />
438
+ </div>
439
+ </div>
440
+ <p className="text-sm font-semibold text-slate-500 uppercase tracking-wide">Accrued Profit</p>
441
+ <h2 className={`text-2xl font-bold mt-1 ${totalOperationalProfit >= 0 ? 'text-violet-700' : 'text-red-600'}`}>
442
+ {totalOperationalProfit >= 0 ? '+' : ''}৳{totalOperationalProfit.toLocaleString()}
443
+ </h2>
444
+ <p className="text-xs text-slate-400 mt-1">Work Value - Actual Expense</p>
445
+ </div>
446
+ </div>
447
+
448
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
449
+ <div className="px-6 py-4 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
450
+ <h3 className="font-semibold text-slate-800">Live Item-Wise Cost Sheet</h3>
451
+ <span className="text-xs font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-2 py-1 rounded flex items-center gap-1">
452
+ <Zap className="w-3 h-3 fill-current" />
453
+ Auto-Synced with DPR
454
+ </span>
455
+ </div>
456
+ <div className="overflow-x-auto">
457
+ <table className="w-full text-left text-sm">
458
+ <thead className="bg-white text-slate-600 font-medium border-b border-slate-200 text-xs uppercase tracking-wider">
459
+ <tr>
460
+ <th className="px-4 py-3 w-8"></th>
461
+ <th className="px-4 py-3">Description</th>
462
+ <th className="px-4 py-3 text-right">Selling Rate</th>
463
+ <th className="px-4 py-3 text-right bg-indigo-50/50 text-indigo-700">Material Exp.</th>
464
+ <th className="px-4 py-3 text-right bg-orange-50/50 text-orange-700">Sub-Contract</th>
465
+ <th className="px-4 py-3 text-right text-slate-500">Est. Labor</th>
466
+ <th className="px-4 py-3 text-right font-bold text-slate-800 border-l border-slate-200">Total Actual</th>
467
+ <th className="px-4 py-3 text-right">Billed (PE)</th>
468
+ <th className="px-4 py-3 text-right">Net Profit</th>
469
+ </tr>
470
+ </thead>
471
+ <tbody className="divide-y divide-slate-100">
472
+ {liveItemStats.map((item) => {
473
+ if (item.executedQty === 0) return null;
474
+ const totalPL = item.stats.profit;
475
+ const workDoneValue = item.executedQty * item.rate;
476
+ const pendingBill = Math.max(0, workDoneValue - (item.billedAmount || 0));
477
+
478
+ return (
479
+ <React.Fragment key={item.id}>
480
+ <tr
481
+ className={`hover:bg-slate-50 transition-colors cursor-pointer ${expandedRow === item.id ? 'bg-slate-50' : ''}`}
482
+ onClick={() => toggleRow(item.id)}
483
+ >
484
+ <td className="px-4 py-4 text-center">
485
+ {expandedRow === item.id ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
486
+ </td>
487
+ <td className="px-4 py-4 font-medium text-slate-700 max-w-[250px]">
488
+ <div className="truncate">{item.description}</div>
489
+ <div className="text-[10px] text-slate-400 font-mono mt-0.5">{item.id} • Qty: {item.executedQty.toLocaleString()} {item.unit}</div>
490
+ </td>
491
+ <td className="px-4 py-4 text-right text-slate-900 font-mono">৳{item.rate.toLocaleString()}</td>
492
+
493
+ <td className="px-4 py-4 text-right font-mono bg-indigo-50/30 text-indigo-700">
494
+ {item.stats.materialExp > 0 ? `৳${item.stats.materialExp.toLocaleString()}` : '-'}
495
+ </td>
496
+ <td className="px-4 py-4 text-right font-mono bg-orange-50/30 text-orange-700">
497
+ {item.stats.subContractExp > 0 ? `৳${item.stats.subContractExp.toLocaleString()}` : '-'}
498
+ </td>
499
+ <td className="px-4 py-4 text-right font-mono text-slate-500">
500
+ {item.stats.directLaborExp > 0 ? `~৳${item.stats.directLaborExp.toLocaleString()}` : '-'}
501
+ </td>
502
+
503
+ <td className="px-4 py-4 text-right font-bold text-slate-900 font-mono border-l border-slate-200 bg-slate-50/50">
504
+ ৳{item.stats.totalActualCost.toLocaleString()}
505
+ </td>
506
+
507
+ <td className="px-4 py-4 text-right text-emerald-700 font-medium">
508
+ ৳{(item.billedAmount || 0).toLocaleString()}
509
+ {pendingBill > 0 && <div className="text-[9px] text-orange-400">Due: ৳{pendingBill.toLocaleString()}</div>}
510
+ </td>
511
+
512
+ <td className={`px-4 py-4 text-right font-bold ${totalPL >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
513
+ ৳{totalPL.toLocaleString()}
514
+ </td>
515
+ </tr>
516
+ {expandedRow === item.id && (
517
+ <tr className="bg-slate-50">
518
+ <td colSpan={9} className="px-6 py-4">
519
+ <div className="ml-8 flex gap-4">
520
+ <div className="flex-1 bg-white p-4 rounded-lg border border-slate-200 shadow-sm">
521
+ <h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">DPR Source Data Breakdown</h4>
522
+ <div className="grid grid-cols-3 gap-4 text-center">
523
+ <div className="p-2 bg-indigo-50 rounded border border-indigo-100">
524
+ <div className="text-[10px] text-indigo-500 font-bold uppercase">Material Consumed</div>
525
+ <div className="font-mono font-bold text-indigo-700">৳{item.stats.materialExp.toLocaleString()}</div>
526
+ <div className="text-[9px] text-indigo-400 mt-1">From Stock Out</div>
527
+ </div>
528
+ <div className="p-2 bg-orange-50 rounded border border-orange-100">
529
+ <div className="text-[10px] text-orange-500 font-bold uppercase">Sub-Contract Work</div>
530
+ <div className="font-mono font-bold text-orange-700">৳{item.stats.subContractExp.toLocaleString()}</div>
531
+ <div className="text-[9px] text-orange-400 mt-1">Based on Agreed Rate</div>
532
+ </div>
533
+ <div className="p-2 bg-slate-50 rounded border border-slate-200">
534
+ <div className="text-[10px] text-slate-500 font-bold uppercase">Direct Labor</div>
535
+ <div className="font-mono font-bold text-slate-700">৳{item.stats.directLaborExp.toLocaleString()}</div>
536
+ <div className="text-[9px] text-slate-400 mt-1">Daily Labor Count</div>
537
+ </div>
538
+ </div>
539
+ </div>
540
+ <div className="w-1/3 bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex flex-col justify-center">
541
+ <div className="flex justify-between items-center mb-2">
542
+ <span className="text-xs font-bold text-slate-500">Actual Unit Cost</span>
543
+ <span className="font-mono font-bold text-slate-800">৳{(item.stats.totalActualCost / item.executedQty).toFixed(2)}</span>
544
+ </div>
545
+ <div className="flex justify-between items-center mb-2">
546
+ <span className="text-xs font-bold text-slate-500">Planned Unit Cost</span>
547
+ <span className="font-mono text-slate-600">৳{item.plannedUnitCost.toFixed(2)}</span>
548
+ </div>
549
+ <div className="h-px bg-slate-100 my-2"></div>
550
+ <div className="flex justify-between items-center">
551
+ <span className="text-xs font-bold text-slate-500">Variance</span>
552
+ <span className={`font-mono font-bold ${item.plannedUnitCost - (item.stats.totalActualCost / item.executedQty) >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
553
+ {item.plannedUnitCost - (item.stats.totalActualCost / item.executedQty) >= 0 ? 'Savings' : 'Overrun'}
554
+ </span>
555
+ </div>
556
+ </div>
557
+ </div>
558
+ </td>
559
+ </tr>
560
+ )}
561
+ </React.Fragment>
562
+ );
563
+ })}
564
+ </tbody>
565
+ </table>
566
+ </div>
567
+ </div>
568
+
569
+ {/* COST CENTER BREAKDOWN SECTION */}
570
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
571
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-fit">
572
+ <div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50 flex items-center gap-2">
573
+ <Hammer className="w-4 h-4 text-indigo-600" />
574
+ <h3 className="font-semibold text-slate-800">Material Cost Breakdown</h3>
575
+ </div>
576
+ <div className="overflow-x-auto">
577
+ <table className="w-full text-left text-sm">
578
+ <thead className="bg-slate-50 text-slate-500 font-medium text-xs uppercase border-b border-slate-100">
579
+ <tr>
580
+ <th className="px-4 py-2">Material</th>
581
+ <th className="px-4 py-2 text-right">Avg Rate</th>
582
+ <th className="px-4 py-2 text-right">Qty Consumed</th>
583
+ <th className="px-4 py-2 text-right">Total Expense</th>
584
+ </tr>
585
+ </thead>
586
+ <tbody className="divide-y divide-slate-50">
587
+ {materialStats.map(m => (
588
+ <tr key={m.id} className="hover:bg-slate-50">
589
+ <td className="px-4 py-2 font-medium text-slate-700">{m.name}</td>
590
+ <td className="px-4 py-2 text-right font-mono text-slate-600">৳{m.averageRate.toLocaleString()}</td>
591
+ <td className="px-4 py-2 text-right font-mono text-slate-600">{m.liveConsumedQty} <span className="text-[10px] text-slate-400">{m.unit}</span></td>
592
+ <td className="px-4 py-2 text-right font-bold text-indigo-600 font-mono">৳{m.liveExpense.toLocaleString()}</td>
593
+ </tr>
594
+ ))}
595
+ {materialStats.length === 0 && (
596
+ <tr><td colSpan={4} className="p-4 text-center text-slate-400 text-xs">No consumption data</td></tr>
597
+ )}
598
+ </tbody>
599
+ </table>
600
+ </div>
601
+ </div>
602
+
603
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-fit">
604
+ <div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50 flex items-center gap-2">
605
+ <UsersRound className="w-4 h-4 text-orange-600" />
606
+ <h3 className="font-semibold text-slate-800">Sub-Contractor Reconciliation</h3>
607
+ </div>
608
+ <div className="overflow-x-auto">
609
+ <table className="w-full text-left text-sm">
610
+ <thead className="bg-slate-50 text-slate-500 font-medium text-xs uppercase border-b border-slate-100">
611
+ <tr>
612
+ <th className="px-4 py-2">Sub-Contractor</th>
613
+ <th className="px-4 py-2 text-right">Work Value</th>
614
+ <th className="px-4 py-2 text-right">Billed</th>
615
+ <th className="px-4 py-2 text-right">Balance</th>
616
+ </tr>
617
+ </thead>
618
+ <tbody className="divide-y divide-slate-50">
619
+ {subContractorStats.map(sc => (
620
+ <tr key={sc.id} className="hover:bg-slate-50">
621
+ <td className="px-4 py-2">
622
+ <div className="font-medium text-slate-700">{sc.name}</div>
623
+ <div className="text-[10px] text-slate-400">{sc.specialization}</div>
624
+ </td>
625
+ <td className="px-4 py-2 text-right font-mono text-slate-600">৳{sc.liveWorkValue.toLocaleString()}</td>
626
+ <td className="px-4 py-2 text-right font-mono text-slate-600">৳{sc.totalBilled.toLocaleString()}</td>
627
+ <td className="px-4 py-2 text-right">
628
+ <span className={`font-mono font-bold ${sc.balance > 0 ? 'text-red-600' : 'text-emerald-600'}`}>
629
+ ৳{sc.balance.toLocaleString()}
630
+ </span>
631
+ {sc.balance > 0 && <AlertOctagon className="w-3 h-3 inline ml-1 text-red-500" />}
632
+ </td>
633
+ </tr>
634
+ ))}
635
+ {subContractorStats.length === 0 && (
636
+ <tr><td colSpan={4} className="p-4 text-center text-slate-400 text-xs">No sub-contractors engaged</td></tr>
637
+ )}
638
+ </tbody>
639
+ </table>
640
+ </div>
641
+ </div>
642
+ </div>
643
+
644
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-[400px]">
645
+ <BillTable bills={clientBills} title="Client RA Bills" />
646
+ <BillTable bills={vendorBills} title="Vendor Invoices (Payables)" />
647
+ </div>
648
+
649
+ {isBillModalOpen && (
650
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
651
+ <div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
652
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
653
+ <div className="flex items-center gap-2">
654
+ <h3 className="font-semibold text-slate-800">
655
+ {billType === 'CLIENT_RA' ? 'Record Bill Received (PE)' : 'Add Expense / Invoice'}
656
+ </h3>
657
+ {aiAutofilled && (
658
+ <div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-50 text-emerald-600 text-[10px] font-bold rounded animate-pulse">
659
+ <CheckCircle2 className="w-3 h-3" />
660
+ AI Auto-Filled
661
+ </div>
662
+ )}
663
+ </div>
664
+ <button onClick={() => setIsBillModalOpen(false)}><X className="w-5 h-5 text-slate-400"/></button>
665
+ </div>
666
+ <form onSubmit={handleCreateBill} className="p-6 space-y-4">
667
+ {billType !== 'CLIENT_RA' && (
668
+ <>
669
+ <div>
670
+ <label className="block text-sm font-medium text-slate-700 mb-1">Expense Type</label>
671
+ <select
672
+ value={billType}
673
+ onChange={(e) => handleBillTypeChange(e.target.value as any)}
674
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white"
675
+ >
676
+ <option value="VENDOR_INVOICE">General Vendor Invoice</option>
677
+ <option value="MATERIAL_EXPENSE">Material Purchase</option>
678
+ <option value="SUB_CONTRACTOR">Sub-Contractor Bill</option>
679
+ </select>
680
+ </div>
681
+ <div>
682
+ <label className="block text-sm font-medium text-slate-700 mb-1">Cost Category</label>
683
+ <select
684
+ value={billCategory}
685
+ onChange={(e) => setBillCategory(e.target.value as any)}
686
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white"
687
+ >
688
+ <option value="MATERIAL">Material</option>
689
+ <option value="LABOR">Labor</option>
690
+ <option value="EQUIPMENT">Equipment</option>
691
+ <option value="OVERHEAD">Overhead</option>
692
+ <option value="OTHER">Other</option>
693
+ </select>
694
+ </div>
695
+ </>
696
+ )}
697
+ <div>
698
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
699
+ Entity Name
700
+ {aiAutofilled && <Sparkles className="w-2.5 h-2.5 text-emerald-500" />}
701
+ </label>
702
+ <input
703
+ type="text"
704
+ required
705
+ value={billEntity}
706
+ onChange={(e) => setBillEntity(e.target.value)}
707
+ placeholder="e.g. ABC Constructions Ltd."
708
+ className={`w-full px-3 py-2 border rounded-lg text-sm transition-all ${aiAutofilled ? 'border-emerald-200 bg-emerald-50/20' : 'border-slate-300'}`}
709
+ />
710
+ </div>
711
+ <div>
712
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
713
+ Amount (৳)
714
+ {aiAutofilled && <Sparkles className="w-2.5 h-2.5 text-emerald-500" />}
715
+ </label>
716
+ <input
717
+ type="number"
718
+ required
719
+ min="0"
720
+ step="0.01"
721
+ value={billAmount}
722
+ onChange={(e) => setBillAmount(e.target.value)}
723
+ className={`w-full px-3 py-2 border rounded-lg text-sm transition-all ${aiAutofilled ? 'border-emerald-200 bg-emerald-50/20' : 'border-slate-300'}`}
724
+ />
725
+ </div>
726
+ <div>
727
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
728
+ Date
729
+ {aiAutofilled && <Sparkles className="w-2.5 h-2.5 text-emerald-500" />}
730
+ </label>
731
+ <input
732
+ type="date"
733
+ required
734
+ value={billDate}
735
+ onChange={(e) => setBillDate(e.target.value)}
736
+ className={`w-full px-3 py-2 border rounded-lg text-sm transition-all ${aiAutofilled ? 'border-emerald-200 bg-emerald-50/20' : 'border-slate-300'}`}
737
+ />
738
+ </div>
739
+
740
+ {billType === 'CLIENT_RA' && detectedBillDocName && (
741
+ <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
742
+ <p className="text-xs text-blue-700">
743
+ <strong>AI Action:</strong> Upon saving, the system will read <em>"{detectedBillDocName}"</em> to automatically distribute the billed amount to individual BOQ items.
744
+ </p>
745
+ </div>
746
+ )}
747
+
748
+ <button type="submit" className="w-full bg-blue-600 text-white py-2 rounded-lg font-bold hover:bg-blue-700">
749
+ Save Record {billType === 'CLIENT_RA' && detectedBillDocName ? '& Auto-Distribute' : ''}
750
+ </button>
751
+ </form>
752
+ </div>
753
+ </div>
754
+ )}
755
+
756
+ <DocumentManager
757
+ documents={data.documents}
758
+ onAddDocument={onAddDocument}
759
+ onBillUploaded={handleBillUploaded}
760
+ filterModule="FINANCE"
761
+ compact={true}
762
+ allowUpload={canUploadDoc}
763
+ />
764
+ </div>
765
+ );
766
+ };
767
+
768
+ export default FinancialControl;
components/GanttChart.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Task } from '../types';
4
+ import { Calendar, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
5
+
6
+ interface GanttChartProps {
7
+ tasks: Task[];
8
+ }
9
+
10
+ const GanttChart: React.FC<GanttChartProps> = ({ tasks }) => {
11
+ const [viewMode, setViewMode] = React.useState<'WEEK' | 'MONTH'>('MONTH');
12
+
13
+ // Simple Gantt Logic
14
+ const sortedTasks = [...tasks].sort((a, b) => new Date(a.startDate || a.createdAt).getTime() - new Date(b.startDate || b.createdAt).getTime());
15
+
16
+ const getTaskDuration = (task: Task) => {
17
+ const start = new Date(task.startDate || task.createdAt);
18
+ const end = new Date(task.dueDate);
19
+ const diffTime = Math.abs(end.getTime() - start.getTime());
20
+ return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
21
+ };
22
+
23
+ return (
24
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col h-full">
25
+ <div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
26
+ <div>
27
+ <h2 className="text-xl font-bold text-slate-800">Project Timeline</h2>
28
+ <p className="text-sm text-slate-500">Visual schedule of all construction activities</p>
29
+ </div>
30
+ <div className="flex items-center gap-3">
31
+ <div className="flex bg-slate-100 p-1 rounded-lg">
32
+ <button
33
+ onClick={() => setViewMode('WEEK')}
34
+ className={`px-3 py-1.5 text-xs font-bold rounded-md transition-all ${viewMode === 'WEEK' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
35
+ >
36
+ Week
37
+ </button>
38
+ <button
39
+ onClick={() => setViewMode('MONTH')}
40
+ className={`px-3 py-1.5 text-xs font-bold rounded-md transition-all ${viewMode === 'MONTH' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
41
+ >
42
+ Month
43
+ </button>
44
+ </div>
45
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
46
+ <Filter className="w-5 h-5" />
47
+ </button>
48
+ </div>
49
+ </div>
50
+
51
+ <div className="flex-1 overflow-auto p-6">
52
+ <div className="min-w-[800px]">
53
+ {/* Timeline Header */}
54
+ <div className="flex border-b border-slate-100 pb-4 mb-6">
55
+ <div className="w-64 shrink-0 font-bold text-slate-400 text-xs uppercase tracking-wider">Activity / Task</div>
56
+ <div className="flex-1 flex justify-between px-4 text-xs font-bold text-slate-400 uppercase tracking-wider">
57
+ <span>Jan</span>
58
+ <span>Feb</span>
59
+ <span>Mar</span>
60
+ <span>Apr</span>
61
+ <span>May</span>
62
+ <span>Jun</span>
63
+ <span>Jul</span>
64
+ <span>Aug</span>
65
+ <span>Sep</span>
66
+ <span>Oct</span>
67
+ <span>Nov</span>
68
+ <span>Dec</span>
69
+ </div>
70
+ </div>
71
+
72
+ {/* Task Bars */}
73
+ <div className="space-y-6">
74
+ {sortedTasks.map((task, idx) => {
75
+ const start = new Date(task.startDate || task.createdAt);
76
+ const startMonth = start.getMonth();
77
+ const duration = getTaskDuration(task);
78
+ const widthPercent = Math.min(100, (duration / 365) * 100 * 12); // Rough estimation
79
+ const leftPercent = (startMonth / 12) * 100;
80
+
81
+ return (
82
+ <div key={task.id} className="flex items-center group">
83
+ <div className="w-64 shrink-0 pr-4">
84
+ <h4 className="font-bold text-slate-700 text-sm truncate group-hover:text-blue-600 transition-colors">{task.title}</h4>
85
+ <span className="text-[10px] text-slate-400 font-medium">{new Date(task.startDate || task.createdAt).toLocaleDateString()} - {new Date(task.dueDate).toLocaleDateString()}</span>
86
+ </div>
87
+ <div className="flex-1 h-8 bg-slate-50 rounded-lg relative overflow-hidden">
88
+ <div
89
+ className={`absolute top-0 bottom-0 rounded-lg shadow-sm transition-all hover:brightness-110 cursor-pointer ${
90
+ task.status === 'COMPLETED' ? 'bg-emerald-500' :
91
+ task.status === 'IN_PROGRESS' ? 'bg-blue-500' :
92
+ task.status === 'BLOCKED' ? 'bg-red-500' : 'bg-slate-300'
93
+ }`}
94
+ style={{
95
+ left: `${leftPercent}%`,
96
+ width: `${Math.max(5, widthPercent)}%`
97
+ }}
98
+ >
99
+ <div className="px-2 py-1 text-[8px] font-bold text-white truncate">
100
+ {task.status}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ })}
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <div className="p-4 bg-slate-50 border-t border-slate-100 flex items-center justify-center gap-6">
112
+ <div className="flex items-center gap-2">
113
+ <div className="w-3 h-3 bg-emerald-500 rounded-full" />
114
+ <span className="text-[10px] font-bold text-slate-500 uppercase">Completed</span>
115
+ </div>
116
+ <div className="flex items-center gap-2">
117
+ <div className="w-3 h-3 bg-blue-500 rounded-full" />
118
+ <span className="text-[10px] font-bold text-slate-500 uppercase">In Progress</span>
119
+ </div>
120
+ <div className="flex items-center gap-2">
121
+ <div className="w-3 h-3 bg-slate-300 rounded-full" />
122
+ <span className="text-[10px] font-bold text-slate-500 uppercase">Pending</span>
123
+ </div>
124
+ <div className="flex items-center gap-2">
125
+ <div className="w-3 h-3 bg-red-500 rounded-full" />
126
+ <span className="text-[10px] font-bold text-slate-500 uppercase">Blocked</span>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ );
131
+ };
132
+
133
+ export default GanttChart;
components/Layout.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import {
4
+ LayoutDashboard,
5
+ FileText,
6
+ HardHat,
7
+ DollarSign,
8
+ AlertTriangle,
9
+ FolderOpen,
10
+ Menu,
11
+ X,
12
+ ChevronLeft,
13
+ UserCircle,
14
+ LogOut,
15
+ Bell,
16
+ CheckCircle2,
17
+ Users,
18
+ Calendar,
19
+ BarChart3,
20
+ ShoppingCart,
21
+ ShieldCheck,
22
+ Camera,
23
+ FileBarChart,
24
+ Truck,
25
+ UserCheck,
26
+ Box,
27
+ Globe
28
+ } from 'lucide-react';
29
+ import { UserRole, User } from '../types';
30
+ import { NotificationCenter } from './Collaboration';
31
+
32
+ interface LayoutProps {
33
+ children: React.ReactNode;
34
+ activeTab: string;
35
+ setActiveTab: (tab: string) => void;
36
+ onSwitchProject: () => void;
37
+ projectName: string;
38
+ user: User;
39
+ onLogout: () => void;
40
+ }
41
+
42
+ const Layout: React.FC<LayoutProps> = ({
43
+ children,
44
+ activeTab,
45
+ setActiveTab,
46
+ onSwitchProject,
47
+ projectName,
48
+ user,
49
+ onLogout
50
+ }) => {
51
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
52
+
53
+ const navItems = [
54
+ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
55
+ { id: 'master', label: 'Master Control', icon: FileText },
56
+ { id: 'site', label: 'Site Execution', icon: HardHat },
57
+ { id: 'finance', label: 'Financial Control', icon: DollarSign },
58
+ { id: 'analytics', label: 'Financial Analytics', icon: BarChart3 },
59
+ { id: 'procurement', label: 'Procurement', icon: ShoppingCart },
60
+ { id: 'equipment', label: 'Equipment', icon: Truck },
61
+ { id: 'labor', label: 'Labor & Attendance', icon: UserCheck },
62
+ { id: 'subcontractors', label: 'Sub-contractors', icon: Users },
63
+ { id: 'qc-safety', label: 'QC & Safety', icon: ShieldCheck },
64
+ { id: 'tasks', label: 'Tasks', icon: CheckCircle2 },
65
+ { id: 'gantt', label: 'Timeline', icon: Calendar },
66
+ { id: 'bim', label: 'BIM Viewer', icon: Box },
67
+ { id: 'photos', label: 'Photo Logs', icon: Camera },
68
+ { id: 'reports', label: 'Reports', icon: FileBarChart },
69
+ { id: 'client', label: 'Client Portal', icon: Globe },
70
+ { id: 'team', label: 'Team', icon: Users },
71
+ { id: 'documents', label: 'Documents', icon: FolderOpen },
72
+ ];
73
+
74
+ const getRoleLabel = (role: string) => {
75
+ switch(role) {
76
+ case 'DIRECTOR': return 'Project Director';
77
+ case 'MANAGER': return 'Project Manager';
78
+ case 'ENGINEER': return 'Site Engineer';
79
+ case 'ACCOUNTANT': return 'Accountant';
80
+ default: return role;
81
+ }
82
+ };
83
+
84
+ const renderNav = () => (
85
+ <nav className="flex flex-col h-full">
86
+ <div className="p-4 border-b border-slate-100">
87
+ <div className="flex items-center gap-2 mb-2 text-slate-800">
88
+ <div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shrink-0">
89
+ <span className="text-white font-bold text-lg">B</span>
90
+ </div>
91
+ <span className="text-xl font-bold tracking-tight">Project Management AI</span>
92
+ </div>
93
+ <button
94
+ onClick={onSwitchProject}
95
+ className="w-full flex items-center gap-2 text-xs font-medium text-slate-500 hover:text-blue-600 hover:bg-blue-50 p-2 rounded transition-colors mt-2"
96
+ >
97
+ <ChevronLeft className="w-3 h-3" />
98
+ Switch Project
99
+ </button>
100
+ <div className="mt-2 text-sm font-semibold text-slate-800 truncate" title={projectName}>
101
+ {projectName}
102
+ </div>
103
+ </div>
104
+
105
+ <div className="flex-1 p-4 space-y-1 overflow-y-auto">
106
+ {navItems.map((item) => (
107
+ <button
108
+ key={item.id}
109
+ onClick={() => {
110
+ setActiveTab(item.id);
111
+ setIsMobileMenuOpen(false);
112
+ }}
113
+ className={`w-full sidebar-item ${
114
+ activeTab === item.id ? 'sidebar-item-active' : ''
115
+ }`}
116
+ >
117
+ <item.icon className="w-4 h-4 shrink-0 transition-transform group-hover:scale-110" />
118
+ <span className="font-medium tracking-tight">{item.label}</span>
119
+ </button>
120
+ ))}
121
+ </div>
122
+
123
+ <div className="p-4 border-t border-slate-200">
124
+ <div className="flex items-center gap-3 px-3 py-2 bg-white border border-slate-200 rounded-xl shadow-sm">
125
+ {user.avatar ? (
126
+ <img src={user.avatar} alt={user.name} className="w-8 h-8 rounded-full border border-slate-100" referrerPolicy="no-referrer" />
127
+ ) : (
128
+ <div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-bold text-xs">
129
+ {user.name.charAt(0)}
130
+ </div>
131
+ )}
132
+ <div className="flex-1 min-w-0">
133
+ <p className="text-xs font-bold text-slate-800 truncate">{user.name}</p>
134
+ <p className="text-[9px] font-bold text-slate-500 uppercase tracking-wider">{getRoleLabel(user.role)}</p>
135
+ </div>
136
+ <button
137
+ onClick={onLogout}
138
+ className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
139
+ title="Logout"
140
+ >
141
+ <LogOut className="w-3.5 h-3.5" />
142
+ </button>
143
+ </div>
144
+ </div>
145
+ </nav>
146
+ );
147
+
148
+ return (
149
+ <div className="flex min-h-screen bg-slate-50">
150
+ {/* Mobile Header */}
151
+ <div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white border-b z-20 flex items-center justify-between px-4">
152
+ <div className="flex items-center gap-3">
153
+ <button onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} className="p-2 text-slate-600">
154
+ {isMobileMenuOpen ? <X /> : <Menu />}
155
+ </button>
156
+ <div className="flex flex-col">
157
+ <span className="font-bold text-lg text-slate-800">Project Management AI</span>
158
+ <span className="text-xs text-slate-500 truncate max-w-[150px]">{projectName}</span>
159
+ </div>
160
+ </div>
161
+ <div className="flex items-center gap-2">
162
+ <NotificationCenter uid={user.uid || ''} />
163
+ {user.avatar && <img src={user.avatar} alt="User" className="w-8 h-8 rounded-full border border-slate-200" referrerPolicy="no-referrer" />}
164
+ </div>
165
+ </div>
166
+
167
+ {/* Sidebar Desktop */}
168
+ <aside className="hidden lg:block w-64 bg-white border-r border-slate-200 fixed h-full z-10">
169
+ {renderNav()}
170
+ </aside>
171
+
172
+ {/* Sidebar Mobile */}
173
+ {isMobileMenuOpen && (
174
+ <aside className="lg:hidden fixed inset-0 z-30 bg-white shadow-xl">
175
+ <div className="flex justify-end p-4">
176
+ <button onClick={() => setIsMobileMenuOpen(false)}><X /></button>
177
+ </div>
178
+ {renderNav()}
179
+ </aside>
180
+ )}
181
+
182
+ {/* Main Content */}
183
+ <main className="flex-1 lg:ml-64 p-4 lg:p-8 pt-20 lg:pt-8 transition-all">
184
+ <div className="max-w-7xl mx-auto">
185
+ <div className="hidden lg:flex justify-end mb-6">
186
+ <NotificationCenter uid={user.uid || ''} />
187
+ </div>
188
+ {children}
189
+ </div>
190
+ </main>
191
+ </div>
192
+ );
193
+ };
194
+
195
+ export default Layout;
components/LiabilityTracker.tsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { ProjectState, ProjectDocument, UserRole } from '../types';
4
+ import { AlertTriangle, Clock, Lock } from 'lucide-react';
5
+ import DocumentManager from './DocumentManager';
6
+
7
+ interface LiabilityTrackerProps {
8
+ data: ProjectState;
9
+ onAddDocument: (doc: ProjectDocument) => void;
10
+ userRole: UserRole;
11
+ }
12
+
13
+ const LiabilityTracker: React.FC<LiabilityTrackerProps> = ({ data, onAddDocument, userRole }) => {
14
+ const retentionTotal = data.liabilities.filter(l => l.type === 'RETENTION').reduce((s, l) => s + l.amount, 0);
15
+ const poTotal = data.liabilities.filter(l => l.type === 'PENDING_PO').reduce((s, l) => s + l.amount, 0);
16
+ const unbilledTotal = data.liabilities.filter(l => l.type === 'UNBILLED_WORK').reduce((s, l) => s + l.amount, 0);
17
+
18
+ const canEdit = userRole === 'DIRECTOR' || userRole === 'ACCOUNTANT';
19
+
20
+ return (
21
+ <div className="space-y-6">
22
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
23
+ <div>
24
+ <h1 className="text-2xl font-bold text-slate-800">Liability Tracker</h1>
25
+ <p className="text-slate-500">Monitor Future Obligations & Risks</p>
26
+ </div>
27
+ </div>
28
+
29
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
30
+ <div className="bg-white p-6 rounded-xl border-l-4 border-indigo-500 shadow-sm">
31
+ <div className="flex items-center gap-3 mb-2">
32
+ <div className="p-2 bg-indigo-50 rounded-lg text-indigo-600">
33
+ <Lock className="w-5 h-5" />
34
+ </div>
35
+ <h3 className="font-semibold text-slate-800">Retention Money</h3>
36
+ </div>
37
+ <p className="text-2xl font-bold text-slate-900">৳{retentionTotal.toLocaleString()}</p>
38
+ <p className="text-sm text-slate-500 mt-1">Held by client, due after defect liability period.</p>
39
+ </div>
40
+
41
+ <div className="bg-white p-6 rounded-xl border-l-4 border-orange-500 shadow-sm">
42
+ <div className="flex items-center gap-3 mb-2">
43
+ <div className="p-2 bg-orange-50 rounded-lg text-orange-600">
44
+ <Clock className="w-5 h-5" />
45
+ </div>
46
+ <h3 className="font-semibold text-slate-800">Pending POs</h3>
47
+ </div>
48
+ <p className="text-2xl font-bold text-slate-900">৳{poTotal.toLocaleString()}</p>
49
+ <p className="text-sm text-slate-500 mt-1">Committed costs for materials ordered but not billed.</p>
50
+ </div>
51
+
52
+ <div className="bg-white p-6 rounded-xl border-l-4 border-red-500 shadow-sm">
53
+ <div className="flex items-center gap-3 mb-2">
54
+ <div className="p-2 bg-red-50 rounded-lg text-red-600">
55
+ <AlertTriangle className="w-5 h-5" />
56
+ </div>
57
+ <h3 className="font-semibold text-slate-800">Unbilled Liabilities</h3>
58
+ </div>
59
+ <p className="text-2xl font-bold text-slate-900">৳{unbilledTotal.toLocaleString()}</p>
60
+ <p className="text-sm text-slate-500 mt-1">Work done by subcontractors, not yet invoiced.</p>
61
+ </div>
62
+ </div>
63
+
64
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
65
+ <div className="px-6 py-4 border-b border-slate-200">
66
+ <h3 className="font-semibold text-slate-800">Detailed Liability Ledger</h3>
67
+ </div>
68
+ <table className="w-full text-left text-sm">
69
+ <thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
70
+ <tr>
71
+ <th className="px-6 py-3">ID</th>
72
+ <th className="px-6 py-3">Description</th>
73
+ <th className="px-6 py-3">Type</th>
74
+ <th className="px-6 py-3">Due Date</th>
75
+ <th className="px-6 py-3 text-right">Amount</th>
76
+ </tr>
77
+ </thead>
78
+ <tbody className="divide-y divide-slate-100">
79
+ {data.liabilities.map(liability => (
80
+ <tr key={liability.id} className="hover:bg-slate-50">
81
+ <td className="px-6 py-3 font-medium text-slate-700">{liability.id}</td>
82
+ <td className="px-6 py-3 text-slate-600">{liability.description}</td>
83
+ <td className="px-6 py-3">
84
+ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
85
+ ${liability.type === 'RETENTION' ? 'bg-indigo-100 text-indigo-800' :
86
+ liability.type === 'PENDING_PO' ? 'bg-orange-100 text-orange-800' :
87
+ 'bg-red-100 text-red-800'}`}>
88
+ {liability.type.replace('_', ' ')}
89
+ </span>
90
+ </td>
91
+ <td className="px-6 py-3 text-slate-500">{liability.dueDate}</td>
92
+ <td className="px-6 py-3 text-right font-medium text-slate-900">৳{liability.amount.toLocaleString()}</td>
93
+ </tr>
94
+ ))}
95
+ </tbody>
96
+ </table>
97
+ </div>
98
+
99
+ <DocumentManager
100
+ documents={data.documents}
101
+ onAddDocument={onAddDocument}
102
+ filterModule="LIABILITY"
103
+ compact={true}
104
+ allowUpload={canEdit}
105
+ />
106
+ </div>
107
+ );
108
+ };
109
+
110
+ export default LiabilityTracker;
components/LocalAssistant.tsx ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { ChatMessage, User } from '../types';
3
+ import { Send, Bot, User as UserIcon, X, Maximize2, Minimize2, Loader2, Sparkles } from 'lucide-react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+
7
+ interface LocalAssistantProps {
8
+ currentUser: User;
9
+ projectContext?: any;
10
+ }
11
+
12
+ const LocalAssistant: React.FC<LocalAssistantProps> = ({ currentUser, projectContext }) => {
13
+ const [isOpen, setIsOpen] = useState(false);
14
+ const [isMinimized, setIsMinimized] = useState(false);
15
+ const [input, setInput] = useState('');
16
+ const [messages, setMessages] = useState<ChatMessage[]>([
17
+ {
18
+ role: 'model',
19
+ parts: [{ text: "Hello! I'm BuildTrack Local Assistant. I can summarize project data without using any external AI service." }]
20
+ }
21
+ ]);
22
+ const [isLoading, setIsLoading] = useState(false);
23
+ const scrollRef = useRef<HTMLDivElement>(null);
24
+
25
+ useEffect(() => {
26
+ if (scrollRef.current) {
27
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
28
+ }
29
+ }, [messages]);
30
+
31
+ const buildLocalResponse = (question: string) => {
32
+ const project = projectContext;
33
+ const normalized = question.toLowerCase();
34
+
35
+ if (!project) {
36
+ return "No project is selected yet. Open a project and I can summarize progress, risks, BOQ status, bills, documents, and pending suggestions from the local project data.";
37
+ }
38
+
39
+ const boq = project.boq || [];
40
+ const bills = project.bills || [];
41
+ const liabilities = project.liabilities || [];
42
+ const dprs = project.dprs || [];
43
+ const documents = project.documents || [];
44
+ const suggestions = project.aiSuggestions || [];
45
+ const planned = boq.reduce((sum: number, item: any) => sum + (item.plannedQty || 0) * (item.rate || 0), 0);
46
+ const executed = boq.reduce((sum: number, item: any) => sum + (item.executedQty || 0) * (item.rate || 0), 0);
47
+ const progress = planned > 0 ? ((executed / planned) * 100).toFixed(1) : "0.0";
48
+ const pendingHigh = boq.filter((item: any) => item.priority === 'HIGH' && (item.executedQty || 0) < (item.plannedQty || 0));
49
+ const money = (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value || 0);
50
+
51
+ if (normalized.includes('risk')) {
52
+ const risk = project.riskAssessment;
53
+ if (risk?.risks?.length) {
54
+ return `Current local risk score is **${risk.overallRiskScore}/100**.\n\n${risk.risks.slice(0, 3).map((r: any) => `- **${r.category} (${r.impact})**: ${r.description} Mitigation: ${r.mitigation}`).join('\n')}`;
55
+ }
56
+ return `No saved risk assessment is available yet. Based on local project data, I would first review ${pendingHigh.length} high-priority pending BOQ item(s), ${liabilities.length} liability record(s), and upcoming milestones.`;
57
+ }
58
+
59
+ if (normalized.includes('bill') || normalized.includes('finance') || normalized.includes('money')) {
60
+ const clientBilled = bills.filter((b: any) => b.type === 'CLIENT_RA').reduce((sum: number, b: any) => sum + (b.amount || 0), 0);
61
+ const expenses = bills.filter((b: any) => b.type !== 'CLIENT_RA').reduce((sum: number, b: any) => sum + (b.amount || 0), 0);
62
+ const liabilityTotal = liabilities.reduce((sum: number, l: any) => sum + (l.amount || 0), 0);
63
+ return `Financial snapshot:\n\n- Client billed: **${money(clientBilled)}**\n- Recorded expenses: **${money(expenses)}**\n- Open liabilities: **${money(liabilityTotal)}**\n- Executed value: **${money(executed)}**`;
64
+ }
65
+
66
+ if (normalized.includes('document') || normalized.includes('file')) {
67
+ return `This project has **${documents.length}** document(s). Pending local suggestions: **${suggestions.filter((s: any) => s.status === 'PENDING').length}**. Use the document manager's scan action to create local suggestions from file names and available text.`;
68
+ }
69
+
70
+ if (normalized.includes('boq') || normalized.includes('progress') || normalized.includes('status')) {
71
+ const pendingText = pendingHigh.length
72
+ ? pendingHigh.slice(0, 3).map((item: any) => `- ${item.description}: ${(item.plannedQty || 0) - (item.executedQty || 0)} ${item.unit} pending`).join('\n')
73
+ : "- No high-priority BOQ items are pending.";
74
+ return `Project status for **${project.name}**:\n\n- Progress: **${progress}%** by value\n- Planned value: **${money(planned)}**\n- Executed value: **${money(executed)}**\n- DPR entries: **${dprs.length}**\n\n${pendingText}`;
75
+ }
76
+
77
+ return `I am running fully locally for ${currentUser.name} (${currentUser.role}). For **${project.name}**, I can summarize progress, BOQ, risks, bills, DPRs, documents, and pending suggestions using data already stored in this app.`;
78
+ };
79
+
80
+ const handleSend = async () => {
81
+ if (!input.trim() || isLoading) return;
82
+
83
+ const question = input.trim();
84
+ const userMessage: ChatMessage = {
85
+ role: 'user',
86
+ parts: [{ text: question }]
87
+ };
88
+
89
+ setMessages(prev => [...prev, userMessage]);
90
+ setInput('');
91
+ setIsLoading(true);
92
+
93
+ try {
94
+ await new Promise(resolve => setTimeout(resolve, 250));
95
+
96
+ const modelResponse: ChatMessage = {
97
+ role: 'model',
98
+ parts: [{ text: buildLocalResponse(question) }]
99
+ };
100
+
101
+ setMessages(prev => [...prev, modelResponse]);
102
+ } catch (error) {
103
+ console.error("Local assistant error:", error);
104
+ setMessages(prev => [...prev, {
105
+ role: 'model',
106
+ parts: [{ text: "I could not process that local request. Please try a shorter question about progress, risk, bills, documents, or BOQ." }]
107
+ }]);
108
+ } finally {
109
+ setIsLoading(false);
110
+ }
111
+ };
112
+
113
+ return (
114
+ <div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
115
+ <AnimatePresence>
116
+ {isOpen && (
117
+ <motion.div
118
+ initial={{ opacity: 0, scale: 0.9, y: 20 }}
119
+ animate={{
120
+ opacity: 1,
121
+ scale: 1,
122
+ y: 0,
123
+ height: isMinimized ? '64px' : '600px',
124
+ width: isMinimized ? '300px' : '400px'
125
+ }}
126
+ exit={{ opacity: 0, scale: 0.9, y: 20 }}
127
+ className="bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden mb-4 flex flex-col max-h-[80vh] w-[90vw] md:w-[400px]"
128
+ >
129
+ {/* Header */}
130
+ <div className="p-4 bg-blue-600 text-white flex items-center justify-between shrink-0">
131
+ <div className="flex items-center gap-2">
132
+ <div className="p-1.5 bg-white/20 rounded-lg">
133
+ <Sparkles className="w-4 h-4 text-white" />
134
+ </div>
135
+ <div>
136
+ <h3 className="font-bold text-sm leading-none">Local Assistant</h3>
137
+ <span className="text-[10px] text-blue-100 animate-pulse">Local & Ready</span>
138
+ </div>
139
+ </div>
140
+ <div className="flex items-center gap-1">
141
+ <button
142
+ onClick={() => setIsMinimized(!isMinimized)}
143
+ className="p-1.5 hover:bg-white/10 rounded-lg transition-colors"
144
+ >
145
+ {isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
146
+ </button>
147
+ <button
148
+ onClick={() => setIsOpen(false)}
149
+ className="p-1.5 hover:bg-white/10 rounded-lg transition-colors"
150
+ >
151
+ <X className="w-4 h-4" />
152
+ </button>
153
+ </div>
154
+ </div>
155
+
156
+ {!isMinimized && (
157
+ <>
158
+ {/* Messages */}
159
+ <div
160
+ ref={scrollRef}
161
+ className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50"
162
+ >
163
+ {messages.map((m, i) => (
164
+ <div
165
+ key={i}
166
+ className={`flex gap-3 ${m.role === 'user' ? 'flex-row-reverse' : ''}`}
167
+ >
168
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
169
+ m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white border border-slate-200 text-blue-600 shadow-sm'
170
+ }`}>
171
+ {m.role === 'user' ? <UserIcon className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
172
+ </div>
173
+ <div className={`max-w-[80%] rounded-2xl p-3 text-sm shadow-sm ${
174
+ m.role === 'user'
175
+ ? 'bg-blue-600 text-white rounded-tr-none'
176
+ : 'bg-white text-slate-700 rounded-tl-none border border-slate-100'
177
+ }`}>
178
+ <div className="prose prose-sm max-w-none prose-slate">
179
+ <ReactMarkdown
180
+ components={{
181
+ p: ({ children }) => <p className="mb-0">{children}</p>,
182
+ ul: ({ children }) => <ul className="my-1 list-disc pl-4">{children}</ul>,
183
+ ol: ({ children }) => <ol className="my-1 list-decimal pl-4">{children}</ol>,
184
+ }}
185
+ >
186
+ {m.parts[0].text}
187
+ </ReactMarkdown>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ ))}
192
+ {isLoading && (
193
+ <div className="flex gap-3">
194
+ <div className="w-8 h-8 rounded-full bg-white border border-slate-200 text-blue-600 flex items-center justify-center shrink-0 shadow-sm">
195
+ <Bot className="w-4 h-4" />
196
+ </div>
197
+ <div className="bg-white border border-slate-100 rounded-2xl rounded-tl-none p-3 shadow-sm">
198
+ <Loader2 className="w-4 h-4 animate-spin text-blue-600" />
199
+ </div>
200
+ </div>
201
+ )}
202
+ </div>
203
+
204
+ {/* Input */}
205
+ <div className="p-4 bg-white border-t border-slate-100 shrink-0">
206
+ <div className="relative">
207
+ <input
208
+ type="text"
209
+ placeholder="Ask local assistant..."
210
+ value={input}
211
+ onChange={(e) => setInput(e.target.value)}
212
+ onKeyDown={(e) => e.key === 'Enter' && handleSend()}
213
+ className="w-full pl-4 pr-12 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-600 outline-none text-sm transition-all"
214
+ />
215
+ <button
216
+ onClick={handleSend}
217
+ disabled={!input.trim() || isLoading}
218
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all disabled:bg-slate-300 disabled:shadow-none shadow-lg shadow-blue-200"
219
+ >
220
+ <Send className="w-4 h-4" />
221
+ </button>
222
+ </div>
223
+ <p className="text-[10px] text-slate-400 mt-2 text-center">
224
+ Local assistant uses project data stored in this app.
225
+ </p>
226
+ </div>
227
+ </>
228
+ )}
229
+ </motion.div>
230
+ )}
231
+ </AnimatePresence>
232
+
233
+ <motion.button
234
+ whileHover={{ scale: 1.05 }}
235
+ whileTap={{ scale: 0.95 }}
236
+ onClick={() => {
237
+ if (!isOpen) setIsOpen(true);
238
+ setIsMinimized(false);
239
+ }}
240
+ className={`w-14 h-14 rounded-full flex items-center justify-center shadow-2xl transition-colors ${
241
+ isOpen ? 'bg-white text-blue-600 border border-blue-100' : 'bg-blue-600 text-white'
242
+ }`}
243
+ >
244
+ <Sparkles className="w-6 h-6" />
245
+ </motion.button>
246
+ </div>
247
+ );
248
+ };
249
+
250
+ export default LocalAssistant;
components/ManualOverrideToggle.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { ShieldAlert } from 'lucide-react';
3
+ import { useNotification } from '../contexts/NotificationContext';
4
+
5
+ const ManualOverrideToggle: React.FC = () => {
6
+ const [isOverride, setIsOverride] = React.useState(false);
7
+ const { showToast } = useNotification();
8
+
9
+ const toggle = () => {
10
+ const newState = !isOverride;
11
+ setIsOverride(newState);
12
+ showToast(`System Override Mode: ${newState ? 'ENABLED' : 'DISABLED'}`, newState ? 'error' : 'info');
13
+ };
14
+
15
+ return (
16
+ <button
17
+ onClick={toggle}
18
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${isOverride ? 'bg-red-600 text-white shadow-lg' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}
19
+ >
20
+ <ShieldAlert className="w-4 h-4" />
21
+ {isOverride ? 'Override ACTIVE' : 'Manual Override'}
22
+ </button>
23
+ );
24
+ };
25
+
26
+ export default ManualOverrideToggle;
components/MasterControl.tsx ADDED
@@ -0,0 +1,1064 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useMemo, useEffect } from 'react';
3
+ import { ProjectState, ProjectDocument, UserRole, BOQItem, Unit, Priority } from '../types';
4
+ import DocumentManager from './DocumentManager';
5
+ import ChangeOrderManager from './ChangeOrderManager';
6
+ import ManualOverrideToggle from './ManualOverrideToggle';
7
+ import {
8
+ PlusCircle,
9
+ X,
10
+ Search,
11
+ Filter,
12
+ ArrowUpDown,
13
+ ChevronDown,
14
+ ArrowUp,
15
+ ArrowDown,
16
+ Activity,
17
+ RotateCcw,
18
+ Sparkles,
19
+ Loader2,
20
+ ChevronUp,
21
+ Layers,
22
+ Flag,
23
+ Save,
24
+ Info,
25
+ CheckCircle2,
26
+ FileText,
27
+ UploadCloud,
28
+ Link as LinkIcon,
29
+ Download,
30
+ FileUp
31
+ } from 'lucide-react';
32
+ import { suggestPlannedUnitCost, parseBOQDocument } from '../services/localAnalysisService';
33
+
34
+ interface MasterControlProps {
35
+ data: ProjectState;
36
+ onAddDocument: (doc: ProjectDocument) => void;
37
+ onAddBOQItem: (item: BOQItem) => void;
38
+ onUpdateBOQItem?: (itemId: string, updatedItem: Partial<BOQItem>) => void;
39
+ onImportBOQItems: (items: BOQItem[]) => void;
40
+ userRole: UserRole;
41
+ }
42
+
43
+ type SortField = 'id' | 'rate' | 'plannedUnitCost' | 'plannedQty' | 'executedQty' | 'progress' | 'revenue' | 'variance' | 'profit' | 'priority';
44
+ type SortDirection = 'asc' | 'desc';
45
+ type StatusFilter = 'ALL' | 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
46
+
47
+ const MasterControl: React.FC<MasterControlProps> = ({ data, onAddDocument, onAddBOQItem, onUpdateBOQItem, onImportBOQItems, userRole }) => {
48
+ const [isModalOpen, setIsModalOpen] = useState(false);
49
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
50
+ const [searchTerm, setSearchTerm] = useState('');
51
+ const [unitFilter, setUnitFilter] = useState<Unit | 'ALL'>('ALL');
52
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
53
+ const [priorityFilter, setPriorityFilter] = useState<Priority | 'ALL'>('ALL');
54
+ const [sortField, setSortField] = useState<SortField>('id');
55
+ const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
56
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
57
+
58
+ // Import State
59
+ const [importTab, setImportTab] = useState<'EXISTING' | 'UPLOAD'>('EXISTING');
60
+ const [selectedFileId, setSelectedFileId] = useState('');
61
+ const [fileToUpload, setFileToUpload] = useState<File | null>(null);
62
+ const [isImporting, setIsImporting] = useState(false);
63
+ const [importStatus, setImportStatus] = useState<string | null>(null);
64
+
65
+ // Editing state for breakdown
66
+ const [editId, setEditId] = useState<string | null>(null);
67
+ const [editMat, setEditMat] = useState<string>('0');
68
+ const [editLab, setEditLab] = useState<string>('0');
69
+ const [editEqp, setEditEqp] = useState<string>('0');
70
+ const [editOH, setEditOH] = useState<string>('0');
71
+
72
+ const canEditBOQ = userRole === 'DIRECTOR' || userRole === 'MANAGER';
73
+
74
+ // Form State for Adding Item
75
+ const [description, setDescription] = useState('');
76
+ const [unit, setUnit] = useState<Unit>(Unit.CUM);
77
+ const [rate, setRate] = useState<string>('');
78
+ const [plannedUnitCost, setPlannedUnitCost] = useState<string>('');
79
+ const [itemPriority, setItemPriority] = useState<Priority>('MEDIUM');
80
+ const [plannedQty, setPlannedQty] = useState<string>('');
81
+
82
+ // Breakdown states for new item
83
+ const [plannedMat, setPlannedMat] = useState<string>('0');
84
+ const [plannedLab, setPlannedLab] = useState<string>('0');
85
+ const [plannedEqp, setPlannedEqp] = useState<string>('0');
86
+ const [plannedOH, setPlannedOH] = useState<string>('0');
87
+
88
+ const [isSuggesting, setIsSuggesting] = useState(false);
89
+ const [aiAppliedFields, setAiAppliedFields] = useState<boolean>(false);
90
+
91
+ // Auto-calculate plannedUnitCost when breakdown changes for NEW item
92
+ useEffect(() => {
93
+ const total = Number(plannedMat) + Number(plannedLab) + Number(plannedEqp) + Number(plannedOH);
94
+ setPlannedUnitCost(total.toString());
95
+ }, [plannedMat, plannedLab, plannedEqp, plannedOH]);
96
+
97
+ // Use all documents for import selection to give user full flexibility
98
+ const availableDocs = useMemo(() => {
99
+ // Filter for documents that are likely to be BOQs (PDF, Excel, etc.)
100
+ return data.documents.filter(d =>
101
+ ['PDF', 'XLSX', 'CSV', 'DOC', 'DOCX'].includes(d.type) ||
102
+ ['CONTRACT', 'REPORT', 'BILL'].includes(d.category)
103
+ );
104
+ }, [data.documents]);
105
+
106
+ const handleAddItem = (e: React.FormEvent) => {
107
+ e.preventDefault();
108
+ const newItem: BOQItem = {
109
+ id: `${(data.boq.length + 1) * 10}-NEW`,
110
+ description,
111
+ unit,
112
+ rate: Number(rate),
113
+ priority: itemPriority,
114
+ plannedUnitCost: Number(plannedUnitCost),
115
+ plannedBreakdown: {
116
+ material: Number(plannedMat),
117
+ labor: Number(plannedLab),
118
+ equipment: Number(plannedEqp),
119
+ overhead: Number(plannedOH)
120
+ },
121
+ plannedQty: Number(plannedQty),
122
+ executedQty: 0
123
+ };
124
+ onAddBOQItem(newItem);
125
+ setIsModalOpen(false);
126
+ resetForm();
127
+ };
128
+
129
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
130
+ if (e.target.files && e.target.files[0]) {
131
+ setFileToUpload(e.target.files[0]);
132
+ }
133
+ };
134
+
135
+ const handleImport = async () => {
136
+ if (importTab === 'EXISTING' && !selectedFileId) return;
137
+ if (importTab === 'UPLOAD' && !fileToUpload) return;
138
+
139
+ setIsImporting(true);
140
+
141
+ try {
142
+ let docName = '';
143
+
144
+ if (importTab === 'UPLOAD' && fileToUpload) {
145
+ setImportStatus('Uploading & Scanning...');
146
+
147
+ // 1. Create and Add Document
148
+ const newDoc: ProjectDocument = {
149
+ id: `D${Date.now()}`,
150
+ name: fileToUpload.name,
151
+ type: fileToUpload.name.split('.').pop()?.toUpperCase() || 'PDF',
152
+ category: 'CONTRACT',
153
+ module: 'MASTER',
154
+ uploadDate: new Date().toISOString().split('T')[0],
155
+ size: `${(fileToUpload.size / (1024 * 1024)).toFixed(2)} MB`,
156
+ url: URL.createObjectURL(fileToUpload),
157
+ isAnalyzed: true
158
+ };
159
+
160
+ onAddDocument(newDoc);
161
+ docName = newDoc.name;
162
+
163
+ // Artificial delay for UX
164
+ await new Promise(resolve => setTimeout(resolve, 1500));
165
+
166
+ } else {
167
+ const file = data.documents.find(d => d.id === selectedFileId);
168
+ if (!file) return;
169
+ docName = file.name;
170
+ setImportStatus('Analyzing existing document...');
171
+ }
172
+
173
+ // 2. Parse Items via AI Service
174
+ const items = await parseBOQDocument(docName);
175
+
176
+ setImportStatus(`Found ${items.length} BOQ items. Syncing...`);
177
+ await new Promise(resolve => setTimeout(resolve, 1000));
178
+
179
+ // 3. Import Items
180
+ onImportBOQItems(items);
181
+
182
+ resetImport();
183
+ } catch (e) {
184
+ setImportStatus('Failed to parse document.');
185
+ } finally {
186
+ setIsImporting(false);
187
+ }
188
+ };
189
+
190
+ const resetImport = () => {
191
+ setIsImportModalOpen(false);
192
+ setSelectedFileId('');
193
+ setFileToUpload(null);
194
+ setImportTab('EXISTING');
195
+ setImportStatus(null);
196
+ };
197
+
198
+ const resetForm = () => {
199
+ setDescription('');
200
+ setRate('');
201
+ setPlannedUnitCost('');
202
+ setPlannedQty('');
203
+ setPlannedMat('0');
204
+ setPlannedLab('0');
205
+ setPlannedEqp('0');
206
+ setPlannedOH('0');
207
+ setItemPriority('MEDIUM');
208
+ setAiAppliedFields(false);
209
+ };
210
+
211
+ const handleSuggestCost = async () => {
212
+ if (!description) return;
213
+ setIsSuggesting(true);
214
+ const suggestion = await suggestPlannedUnitCost(description, unit, data.boq);
215
+ setIsSuggesting(false);
216
+
217
+ if (suggestion) {
218
+ setPlannedMat(suggestion.breakdown.material.toString());
219
+ setPlannedLab(suggestion.breakdown.labor.toString());
220
+ setPlannedEqp(suggestion.breakdown.equipment.toString());
221
+ setPlannedOH(suggestion.breakdown.overhead.toString());
222
+ setAiAppliedFields(true);
223
+
224
+ // Reset highlight after 3 seconds
225
+ setTimeout(() => setAiAppliedFields(false), 3000);
226
+ }
227
+ };
228
+
229
+ const startEditing = (item: BOQItem) => {
230
+ setEditId(item.id);
231
+ setEditMat(item.plannedBreakdown?.material?.toString() || '0');
232
+ setEditLab(item.plannedBreakdown?.labor?.toString() || '0');
233
+ setEditEqp(item.plannedBreakdown?.equipment?.toString() || '0');
234
+ setEditOH(item.plannedBreakdown?.overhead?.toString() || '0');
235
+ };
236
+
237
+ const cancelEditing = () => {
238
+ setEditId(null);
239
+ };
240
+
241
+ const saveEditing = (id: string) => {
242
+ if (!onUpdateBOQItem) return;
243
+ const total = Number(editMat) + Number(editLab) + Number(editEqp) + Number(editOH);
244
+ onUpdateBOQItem(id, {
245
+ plannedUnitCost: total,
246
+ plannedBreakdown: {
247
+ material: Number(editMat),
248
+ labor: Number(editLab),
249
+ equipment: Number(editEqp),
250
+ overhead: Number(editOH)
251
+ }
252
+ });
253
+ setEditId(null);
254
+ };
255
+
256
+ const handleLinkDocument = (itemId: string, docId: string) => {
257
+ if (onUpdateBOQItem) {
258
+ onUpdateBOQItem(itemId, { linkedDocId: docId || undefined });
259
+ }
260
+ };
261
+
262
+ const toggleRow = (id: string) => {
263
+ const newExpanded = new Set(expandedRows);
264
+ if (newExpanded.has(id)) {
265
+ newExpanded.delete(id);
266
+ if (editId === id) setEditId(null);
267
+ } else {
268
+ newExpanded.add(id);
269
+ }
270
+ setExpandedRows(newExpanded);
271
+ };
272
+
273
+ const handleSort = (field: SortField) => {
274
+ if (sortField === field) {
275
+ setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
276
+ } else {
277
+ setSortField(field);
278
+ setSortDirection('asc');
279
+ }
280
+ };
281
+
282
+ const clearFilters = () => {
283
+ setSearchTerm('');
284
+ setUnitFilter('ALL');
285
+ setStatusFilter('ALL');
286
+ setPriorityFilter('ALL');
287
+ };
288
+
289
+ const hasActiveFilters = searchTerm !== '' || unitFilter !== 'ALL' || statusFilter !== 'ALL' || priorityFilter !== 'ALL';
290
+
291
+ const getPriorityWeight = (p?: Priority) => {
292
+ if (p === 'HIGH') return 3;
293
+ if (p === 'MEDIUM') return 2;
294
+ if (p === 'LOW') return 1;
295
+ return 0;
296
+ };
297
+
298
+ // Group documents by category for the dropdown
299
+ const groupedDocuments = useMemo(() => {
300
+ const groups: Record<string, ProjectDocument[]> = {};
301
+ data.documents.forEach(doc => {
302
+ if (!groups[doc.category]) groups[doc.category] = [];
303
+ groups[doc.category].push(doc);
304
+ });
305
+ return groups;
306
+ }, [data.documents]);
307
+
308
+ const filteredAndSortedBOQ = useMemo(() => {
309
+ return data.boq
310
+ .filter(item => {
311
+ const matchesSearch = item.description.toLowerCase().includes(searchTerm.toLowerCase()) || item.id.toLowerCase().includes(searchTerm.toLowerCase());
312
+ const matchesUnit = unitFilter === 'ALL' || item.unit === unitFilter;
313
+ const matchesPriority = priorityFilter === 'ALL' || item.priority === priorityFilter;
314
+
315
+ let currentStatus: StatusFilter = 'PENDING';
316
+ if (item.executedQty >= item.plannedQty) currentStatus = 'COMPLETED';
317
+ else if (item.executedQty > 0) currentStatus = 'IN_PROGRESS';
318
+
319
+ const matchesStatus = statusFilter === 'ALL' || currentStatus === statusFilter;
320
+
321
+ return matchesSearch && matchesUnit && matchesStatus && matchesPriority;
322
+ })
323
+ .sort((a, b) => {
324
+ let valA: any, valB: any;
325
+
326
+ const getVariance = (item: BOQItem) => {
327
+ if (item.executedQty === 0 || !item.costAnalysis) return 0;
328
+ return item.plannedUnitCost - item.costAnalysis.unitCost;
329
+ };
330
+
331
+ const getProfit = (item: BOQItem) => {
332
+ if (item.executedQty === 0) return 0;
333
+ const cost = item.costAnalysis?.unitCost || item.plannedUnitCost;
334
+ return (item.rate - cost) * item.executedQty;
335
+ };
336
+
337
+ switch (sortField) {
338
+ case 'rate': valA = a.rate; valB = b.rate; break;
339
+ case 'plannedUnitCost': valA = a.plannedUnitCost; valB = b.plannedUnitCost; break;
340
+ case 'plannedQty': valA = a.plannedQty; valB = b.plannedQty; break;
341
+ case 'executedQty': valA = a.executedQty; valB = b.executedQty; break;
342
+ case 'variance': valA = getVariance(a); valB = getVariance(b); break;
343
+ case 'profit': valA = getProfit(a); valB = getProfit(b); break;
344
+ case 'priority': valA = getPriorityWeight(a.priority); valB = getPriorityWeight(b.priority); break;
345
+ case 'progress':
346
+ valA = (a.executedQty / a.plannedQty) || 0;
347
+ valB = (b.executedQty / b.plannedQty) || 0;
348
+ break;
349
+ case 'revenue': valA = a.rate * a.plannedQty; valB = b.rate * b.plannedQty; break;
350
+ default: valA = a.id; valB = b.id;
351
+ }
352
+
353
+ if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
354
+ if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
355
+ return 0;
356
+ });
357
+ }, [data.boq, searchTerm, unitFilter, statusFilter, priorityFilter, sortField, sortDirection]);
358
+
359
+ const SortIcon = ({ field }: { field: SortField }) => {
360
+ if (sortField !== field) return <ArrowUpDown className="w-3.5 h-3.5 ml-1 inline opacity-30 group-hover:opacity-100 transition-opacity" />;
361
+ return sortDirection === 'asc' ? <ArrowUp className="w-3.5 h-3.5 ml-1 inline text-blue-600" /> : <ArrowDown className="w-3.5 h-3.5 ml-1 inline text-blue-600" />;
362
+ };
363
+
364
+ const getPriorityColor = (p?: Priority) => {
365
+ switch(p) {
366
+ case 'HIGH': return 'bg-red-50 text-red-600 border-red-100';
367
+ case 'MEDIUM': return 'bg-amber-50 text-amber-600 border-amber-100';
368
+ case 'LOW': return 'bg-blue-50 text-blue-600 border-blue-100';
369
+ default: return 'bg-slate-50 text-slate-600 border-slate-100';
370
+ }
371
+ };
372
+
373
+ return (
374
+ <div className="space-y-6">
375
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
376
+ <div>
377
+ <h1 className="text-2xl font-bold text-slate-800">Master Control (Baseline)</h1>
378
+ <p className="text-slate-500">Fixed Contract Data, BOQ & Budget</p>
379
+ </div>
380
+ </div>
381
+
382
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
383
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
384
+ <h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Project Name</h4>
385
+ <p className="text-lg font-medium text-slate-900 mt-1">{data.name}</p>
386
+ </div>
387
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
388
+ <h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Contract Duration</h4>
389
+ <p className="text-lg font-medium text-slate-900 mt-1">{data.startDate} to {data.endDate}</p>
390
+ </div>
391
+ <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
392
+ <h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Total Contract Value</h4>
393
+ <p className="text-lg font-medium text-slate-900 mt-1">৳{data.contractValue.toLocaleString()}</p>
394
+ </div>
395
+ </div>
396
+
397
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
398
+ {/* Table Toolbar */}
399
+ <div className="px-6 py-4 border-b border-slate-200 flex flex-col xl:flex-row justify-between items-start xl:items-center gap-4 bg-slate-50/50">
400
+ <div className="flex items-center gap-3">
401
+ <h3 className="font-semibold text-slate-800">Bill of Quantities (BOQ)</h3>
402
+ <span className="text-xs font-medium bg-white border border-slate-200 text-slate-500 px-2 py-1 rounded">Rev 1.0</span>
403
+ </div>
404
+
405
+ <div className="flex flex-wrap items-center gap-2 w-full xl:w-auto">
406
+ <div className="relative flex-1 min-w-[150px] xl:flex-none">
407
+ <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
408
+ <input
409
+ type="text"
410
+ placeholder="Search items..."
411
+ value={searchTerm}
412
+ onChange={(e) => setSearchTerm(e.target.value)}
413
+ className="w-full pl-9 pr-4 py-1.5 text-sm bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
414
+ />
415
+ </div>
416
+
417
+ <div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-slate-300 rounded-lg">
418
+ <Filter className="w-3.5 h-3.5 text-slate-400" />
419
+ <select
420
+ value={unitFilter}
421
+ onChange={(e) => setUnitFilter(e.target.value as any)}
422
+ className="text-xs font-medium text-slate-600 outline-none bg-transparent"
423
+ >
424
+ <option value="ALL">All Units</option>
425
+ {Object.values(Unit).map(u => <option key={u} value={u}>{u}</option>)}
426
+ </select>
427
+ </div>
428
+
429
+ <div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-slate-300 rounded-lg">
430
+ <Flag className="w-3.5 h-3.5 text-slate-400" />
431
+ <select
432
+ value={priorityFilter}
433
+ onChange={(e) => setPriorityFilter(e.target.value as any)}
434
+ className="text-xs font-medium text-slate-600 outline-none bg-transparent"
435
+ >
436
+ <option value="ALL">All Priority</option>
437
+ <option value="LOW">Low</option>
438
+ <option value="MEDIUM">Medium</option>
439
+ <option value="HIGH">High</option>
440
+ </select>
441
+ </div>
442
+
443
+ {hasActiveFilters && (
444
+ <button
445
+ onClick={clearFilters}
446
+ className="flex items-center gap-1.5 text-xs font-semibold text-slate-500 hover:text-blue-600 bg-slate-100 px-3 py-1.5 rounded-lg transition-colors border border-slate-200"
447
+ >
448
+ <RotateCcw className="w-3.5 h-3.5" />
449
+ Clear
450
+ </button>
451
+ )}
452
+
453
+ <div className="h-6 w-px bg-slate-300 mx-1 hidden xl:block"></div>
454
+
455
+ {canEditBOQ && (
456
+ <>
457
+ <ManualOverrideToggle />
458
+ <button
459
+ onClick={() => setIsImportModalOpen(true)}
460
+ className="flex items-center gap-2 bg-indigo-50 border border-indigo-200 text-indigo-700 px-3 py-1.5 rounded-lg hover:bg-indigo-100 transition-colors shadow-sm text-sm font-medium"
461
+ >
462
+ <UploadCloud className="w-4 h-4" />
463
+ Import BOQ
464
+ </button>
465
+ <button
466
+ onClick={() => setIsModalOpen(true)}
467
+ className="flex items-center gap-2 bg-blue-600 text-white px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors shadow-sm text-sm font-medium"
468
+ >
469
+ <PlusCircle className="w-4 h-4" />
470
+ Add Item
471
+ </button>
472
+ </>
473
+ )}
474
+ </div>
475
+ </div>
476
+
477
+ <div className="overflow-x-auto">
478
+ <table className="w-full text-left text-sm border-collapse">
479
+ <thead className="bg-white text-slate-600 font-medium border-b border-slate-200">
480
+ <tr>
481
+ <th className="px-6 py-4 w-8"></th>
482
+ <th className="px-6 py-4 cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('id')}>
483
+ ID <SortIcon field="id" />
484
+ </th>
485
+ <th className="px-6 py-4">Description & Status</th>
486
+ <th className="px-6 py-4 cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('priority')}>
487
+ Priority <SortIcon field="priority" />
488
+ </th>
489
+ <th className="px-6 py-4">Unit</th>
490
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('rate')}>
491
+ Rate (৳) <SortIcon field="rate" />
492
+ </th>
493
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('variance')}>
494
+ Cost Var. <SortIcon field="variance" />
495
+ </th>
496
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('profit')}>
497
+ Profit Cont. <SortIcon field="profit" />
498
+ </th>
499
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('plannedQty')}>
500
+ Planned <SortIcon field="plannedQty" />
501
+ </th>
502
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('executedQty')}>
503
+ Executed <SortIcon field="executedQty" />
504
+ </th>
505
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('progress')}>
506
+ Progress <SortIcon field="progress" />
507
+ </th>
508
+ <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('revenue')}>
509
+ Total Rev (৳) <SortIcon field="revenue" />
510
+ </th>
511
+ </tr>
512
+ </thead>
513
+ <tbody className="divide-y divide-slate-100">
514
+ {filteredAndSortedBOQ.map((item) => {
515
+ const progress = Math.min(100, (item.executedQty / item.plannedQty) * 100) || 0;
516
+ const isCompleted = item.executedQty >= item.plannedQty;
517
+ const isInProgress = item.executedQty > 0 && !isCompleted;
518
+ const isExpanded = expandedRows.has(item.id);
519
+ const isEditing = editId === item.id;
520
+
521
+ // Finance Analysis
522
+ const hasActualCost = item.executedQty > 0 && !!item.costAnalysis;
523
+ const actualCost = item.costAnalysis?.unitCost || 0;
524
+ const costVariance = hasActualCost ? (item.plannedUnitCost - actualCost) : 0;
525
+ const margin = hasActualCost ? (item.rate - actualCost) : (item.rate - item.plannedUnitCost);
526
+ const profitContribution = item.executedQty > 0 ? (margin * item.executedQty) : 0;
527
+
528
+ const currentTotal = isEditing ? (Number(editMat) + Number(editLab) + Number(editEqp) + Number(editOH)) : item.plannedUnitCost;
529
+
530
+ return (
531
+ <React.Fragment key={item.id}>
532
+ <tr
533
+ className={`hover:bg-slate-50/50 transition-colors group cursor-pointer ${isExpanded ? 'bg-slate-50/80' : ''}`}
534
+ onClick={() => toggleRow(item.id)}
535
+ >
536
+ <td className="px-4 py-4 text-slate-400">
537
+ {isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
538
+ </td>
539
+ <td className="px-6 py-4 font-medium text-slate-900 whitespace-nowrap">{item.id}</td>
540
+ <td className="px-6 py-4 text-slate-700 min-w-[280px]">
541
+ <div className="font-medium text-slate-800 line-clamp-1 group-hover:line-clamp-none transition-all duration-300">
542
+ {item.description}
543
+ </div>
544
+ <div className="mt-1.5 flex items-center gap-2">
545
+ {isCompleted ? (
546
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase tracking-tight">Completed</span>
547
+ ) : isInProgress ? (
548
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 border border-blue-100 uppercase tracking-tight">In Progress</span>
549
+ ) : (
550
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-600 border border-slate-200 uppercase tracking-tight">Pending</span>
551
+ )}
552
+ <span className="text-[10px] text-slate-400 font-medium">Budget: ৳{item.plannedUnitCost.toLocaleString()}</span>
553
+
554
+ {/* Linked Doc Indicator */}
555
+ {item.linkedDocId && (
556
+ <span className="flex items-center gap-1 text-[10px] bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded border border-indigo-100 font-medium" title="Has Linked Document">
557
+ <LinkIcon className="w-3 h-3" />
558
+ Doc
559
+ </span>
560
+ )}
561
+ </div>
562
+ </td>
563
+ <td className="px-6 py-4">
564
+ <div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold border uppercase tracking-wider ${getPriorityColor(item.priority)}`}>
565
+ <Flag className="w-2.5 h-2.5" />
566
+ {item.priority || 'N/A'}
567
+ </div>
568
+ </td>
569
+ <td className="px-6 py-4">
570
+ <span className="text-xs font-bold bg-slate-100 px-2 py-0.5 rounded text-slate-500">
571
+ {item.unit}
572
+ </span>
573
+ </td>
574
+ <td className="px-6 py-4 text-right text-slate-700 font-mono">
575
+ {item.rate.toLocaleString(undefined, { minimumFractionDigits: 2 })}
576
+ </td>
577
+ <td className="px-6 py-4 text-right">
578
+ {hasActualCost ? (
579
+ <div className="flex flex-col items-end">
580
+ <span className={`font-mono text-xs font-bold ${costVariance >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
581
+ {costVariance >= 0 ? '+' : ''}{costVariance.toFixed(2)}
582
+ </span>
583
+ </div>
584
+ ) : (
585
+ <span className="text-slate-300 font-mono text-xs italic">N/A</span>
586
+ )}
587
+ </td>
588
+ <td className="px-6 py-4 text-right">
589
+ {item.executedQty > 0 ? (
590
+ <div className="flex flex-col items-end">
591
+ <span className={`font-mono text-xs font-bold ${profitContribution >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
592
+ ৳{profitContribution.toLocaleString(undefined, { maximumFractionDigits: 0 })}
593
+ </span>
594
+ </div>
595
+ ) : (
596
+ <span className="text-slate-300 font-mono text-xs">0</span>
597
+ )}
598
+ </td>
599
+ <td className="px-6 py-4 text-right text-slate-600 font-mono">
600
+ {item.plannedQty.toLocaleString()}
601
+ </td>
602
+ <td className="px-6 py-4 text-right text-slate-900 font-semibold font-mono">
603
+ {item.executedQty.toLocaleString()}
604
+ </td>
605
+ <td className="px-6 py-4 text-right">
606
+ <div className="flex flex-col items-end gap-1">
607
+ <span className={`text-[11px] font-bold ${progress >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-600' : 'text-slate-400'}`}>
608
+ {progress.toFixed(1)}%
609
+ </span>
610
+ <div className="w-16 h-1 bg-slate-100 rounded-full overflow-hidden">
611
+ <div
612
+ className={`h-full transition-all duration-500 ${progress >= 100 ? 'bg-emerald-500' : 'bg-blue-500'}`}
613
+ style={{ width: `${progress}%` }}
614
+ ></div>
615
+ </div>
616
+ </div>
617
+ </td>
618
+ <td className="px-6 py-4 text-right font-bold text-slate-900 font-mono">
619
+ {(item.rate * item.plannedQty).toLocaleString(undefined, { minimumFractionDigits: 2 })}
620
+ </td>
621
+ </tr>
622
+
623
+ {isExpanded && (
624
+ <tr className="bg-slate-50 border-y border-slate-200" onClick={(e) => e.stopPropagation()}>
625
+ <td colSpan={12} className="px-6 py-6">
626
+ <div className="max-w-4xl bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden ml-8">
627
+ <div className="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
628
+ <div className="flex items-center gap-2">
629
+ <Layers className="w-4 h-4 text-indigo-500" />
630
+ <span className="text-xs font-bold text-slate-600 uppercase tracking-wider">Item Details & Cost Breakdown</span>
631
+ </div>
632
+ {!isEditing && canEditBOQ && (
633
+ <button
634
+ onClick={() => startEditing(item)}
635
+ className="text-xs font-bold text-blue-600 hover:text-blue-700 bg-blue-50 px-3 py-1 rounded-lg transition-colors border border-blue-100"
636
+ >
637
+ Edit Components
638
+ </button>
639
+ )}
640
+ {isEditing && (
641
+ <div className="flex items-center gap-2">
642
+ <button
643
+ onClick={cancelEditing}
644
+ className="text-xs font-bold text-slate-500 hover:text-slate-700 px-3 py-1 rounded-lg transition-colors"
645
+ >
646
+ Cancel
647
+ </button>
648
+ <button
649
+ onClick={() => saveEditing(item.id)}
650
+ className="text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-700 px-4 py-1.5 rounded-lg transition-colors shadow-sm flex items-center gap-1.5"
651
+ >
652
+ <Save className="w-3 h-3" />
653
+ Save Changes
654
+ </button>
655
+ </div>
656
+ )}
657
+ </div>
658
+
659
+ <div className="p-6">
660
+ {/* Document Link Section */}
661
+ <div className="mb-6 bg-slate-50 p-3 rounded-lg border border-slate-200 flex items-center gap-3">
662
+ <div className="p-2 bg-white rounded-md border border-slate-200 text-slate-400">
663
+ <LinkIcon className="w-4 h-4" />
664
+ </div>
665
+ <div className="flex-1">
666
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Linked Reference Document</label>
667
+ <select
668
+ value={item.linkedDocId || ''}
669
+ onChange={(e) => handleLinkDocument(item.id, e.target.value)}
670
+ disabled={!canEditBOQ}
671
+ className="w-full bg-transparent text-sm font-medium text-slate-700 outline-none border-none p-0 focus:ring-0 cursor-pointer hover:bg-slate-100 rounded"
672
+ >
673
+ <option value="">Select a document to link (Contract, Bill, Drawing)...</option>
674
+ {Object.entries(groupedDocuments).map(([category, docs]) => (
675
+ <optgroup key={category} label={category}>
676
+ {docs.map(d => (
677
+ <option key={d.id} value={d.id}>
678
+ {d.name} ({d.uploadDate})
679
+ </option>
680
+ ))}
681
+ </optgroup>
682
+ ))}
683
+ {Object.keys(groupedDocuments).length === 0 && (
684
+ <option disabled value="">No documents available</option>
685
+ )}
686
+ </select>
687
+ </div>
688
+ {item.linkedDocId && (
689
+ <a
690
+ href={data.documents.find(d => d.id === item.linkedDocId)?.url}
691
+ download={data.documents.find(d => d.id === item.linkedDocId)?.name}
692
+ className="text-blue-600 hover:text-blue-800 p-2 hover:bg-blue-50 rounded-full transition-colors"
693
+ title="Download Linked Document"
694
+ >
695
+ <Download className="w-4 h-4" />
696
+ </a>
697
+ )}
698
+ </div>
699
+
700
+ <div className="grid grid-cols-4 gap-8 mb-8">
701
+ {/* Component Inputs/Views */}
702
+ {[
703
+ { label: 'Material', value: item.plannedBreakdown?.material || 0, editValue: editMat, setEdit: setEditMat, color: 'bg-blue-500' },
704
+ { label: 'Labor', value: item.plannedBreakdown?.labor || 0, editValue: editLab, setEdit: setEditLab, color: 'bg-amber-500' },
705
+ { label: 'Equipment', value: item.plannedBreakdown?.equipment || 0, editValue: editEqp, setEdit: setEditEqp, color: 'bg-emerald-500' },
706
+ { label: 'Overhead', value: item.plannedBreakdown?.overhead || 0, editValue: editOH, setEdit: setEditOH, color: 'bg-violet-500' },
707
+ ].map((comp, idx) => {
708
+ const percent = (isEditing ? Number(comp.editValue) : comp.value) / (currentTotal || 1) * 100 || 0;
709
+ return (
710
+ <div key={idx} className="space-y-3">
711
+ <div className="flex justify-between items-center">
712
+ <p className="text-[11px] font-bold text-slate-400 uppercase tracking-wide">{comp.label}</p>
713
+ <span className="text-[10px] font-bold text-slate-400">{percent.toFixed(0)}%</span>
714
+ </div>
715
+ {isEditing ? (
716
+ <div className="relative">
717
+ <span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs font-mono">৳</span>
718
+ <input
719
+ type="number"
720
+ value={comp.editValue}
721
+ onChange={(e) => comp.setEdit(e.target.value)}
722
+ className="w-full pl-7 pr-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono font-bold"
723
+ />
724
+ </div>
725
+ ) : (
726
+ <p className="text-xl font-mono font-bold text-slate-800">৳{comp.value.toLocaleString()}</p>
727
+ )}
728
+ <div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden shadow-inner">
729
+ <div className={`h-full transition-all duration-500 ${comp.color}`} style={{ width: `${percent}%` }}></div>
730
+ </div>
731
+ </div>
732
+ );
733
+ })}
734
+ </div>
735
+
736
+ <div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-200">
737
+ <div className="flex items-center gap-2">
738
+ <Info className="w-4 h-4 text-blue-500" />
739
+ <span className="text-sm font-semibold text-slate-600">Calculated Planned Unit Cost:</span>
740
+ </div>
741
+ <div className="flex items-center gap-3">
742
+ {isEditing && currentTotal !== item.plannedUnitCost && (
743
+ <span className={`text-xs font-bold px-2 py-0.5 rounded ${currentTotal > item.plannedUnitCost ? 'bg-red-100 text-red-600' : 'bg-emerald-100 text-emerald-600'}`}>
744
+ {currentTotal > item.plannedUnitCost ? 'Increased' : 'Decreased'} by ৳{Math.abs(currentTotal - item.plannedUnitCost).toLocaleString()}
745
+ </span>
746
+ )}
747
+ <span className="text-2xl font-mono font-black text-indigo-700">
748
+ ৳{currentTotal.toLocaleString(undefined, { minimumFractionDigits: 2 })}
749
+ </span>
750
+ </div>
751
+ </div>
752
+ </div>
753
+ </div>
754
+ </td>
755
+ </tr>
756
+ )}
757
+ </React.Fragment>
758
+ );
759
+ })}
760
+ </tbody>
761
+ </table>
762
+ </div>
763
+ </div>
764
+
765
+ <div className="pt-10 border-t border-slate-200">
766
+ <div className="flex items-center gap-3 mb-6">
767
+ <div className="p-2 bg-amber-100 text-amber-600 rounded-lg">
768
+ <Activity className="w-6 h-6" />
769
+ </div>
770
+ <div>
771
+ <h2 className="text-xl font-bold text-slate-800">Change Order Management</h2>
772
+ <p className="text-sm text-slate-500">Track extra work requests and contract variations</p>
773
+ </div>
774
+ </div>
775
+ <ChangeOrderManager changeOrders={data.changeOrders || []} />
776
+ </div>
777
+
778
+ {/* Import BOQ Modal */}
779
+ {isImportModalOpen && (
780
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
781
+ <div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden">
782
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-indigo-50/50">
783
+ <div className="flex items-center gap-2 text-indigo-800">
784
+ <UploadCloud className="w-5 h-5" />
785
+ <h3 className="font-bold">Import BOQ Items</h3>
786
+ </div>
787
+ <button onClick={resetImport} className="text-slate-400 hover:text-slate-600">
788
+ <X className="w-5 h-5" />
789
+ </button>
790
+ </div>
791
+
792
+ {/* Tabs */}
793
+ <div className="flex border-b border-slate-200">
794
+ <button
795
+ className={`flex-1 py-3 text-sm font-medium border-b-2 transition-colors ${importTab === 'EXISTING' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
796
+ onClick={() => setImportTab('EXISTING')}
797
+ >
798
+ Select Existing
799
+ </button>
800
+ <button
801
+ className={`flex-1 py-3 text-sm font-medium border-b-2 transition-colors ${importTab === 'UPLOAD' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
802
+ onClick={() => setImportTab('UPLOAD')}
803
+ >
804
+ Upload New
805
+ </button>
806
+ </div>
807
+
808
+ <div className="p-6 space-y-4">
809
+ <p className="text-sm text-slate-600">
810
+ AI will extract items, quantities, and rates from the document to populate the master schedule.
811
+ </p>
812
+
813
+ {importTab === 'EXISTING' ? (
814
+ <div>
815
+ <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight mb-2">Select Existing Document</label>
816
+ <div className="relative">
817
+ <FileText className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
818
+ <select
819
+ value={selectedFileId}
820
+ onChange={(e) => setSelectedFileId(e.target.value)}
821
+ className="w-full pl-9 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
822
+ >
823
+ <option value="">-- Choose BOQ File --</option>
824
+ {availableDocs.map(f => (
825
+ <option key={f.id} value={f.id}>{f.name} ({f.category})</option>
826
+ ))}
827
+ </select>
828
+ </div>
829
+ {availableDocs.length === 0 && (
830
+ <p className="text-xs text-red-500 mt-1">No compatible files found (PDF/Excel).</p>
831
+ )}
832
+ </div>
833
+ ) : (
834
+ <div>
835
+ <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight mb-2">Upload File</label>
836
+ <div className={`border-2 border-dashed rounded-xl p-6 text-center transition-colors relative group ${fileToUpload ? 'border-indigo-400 bg-indigo-50' : 'border-slate-300 hover:bg-slate-50'}`}>
837
+ <input
838
+ type="file"
839
+ onChange={handleFileChange}
840
+ accept=".pdf,.xlsx,.csv,.doc,.docx"
841
+ disabled={isImporting}
842
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
843
+ />
844
+ <div className="flex flex-col items-center gap-2">
845
+ <div className={`p-3 rounded-full ${fileToUpload ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-100 text-slate-500'}`}>
846
+ {fileToUpload ? <FileUp className="w-6 h-6" /> : <UploadCloud className="w-6 h-6" />}
847
+ </div>
848
+ <p className="text-sm font-medium text-slate-700 max-w-[200px] truncate">
849
+ {fileToUpload ? fileToUpload.name : "Click to browse or drag file"}
850
+ </p>
851
+ {!fileToUpload && <p className="text-xs text-slate-400">PDF, Excel, or Word</p>}
852
+ </div>
853
+ </div>
854
+ </div>
855
+ )}
856
+
857
+ {importStatus && (
858
+ <div className="p-3 bg-indigo-50 text-indigo-700 text-sm rounded-lg flex items-center gap-2 animate-in fade-in slide-in-from-top-2">
859
+ {isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle2 className="w-4 h-4" />}
860
+ {importStatus}
861
+ </div>
862
+ )}
863
+
864
+ <div className="pt-2 flex justify-end gap-2">
865
+ <button
866
+ onClick={resetImport}
867
+ className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50 rounded-lg"
868
+ >
869
+ Cancel
870
+ </button>
871
+ <button
872
+ onClick={handleImport}
873
+ disabled={(importTab === 'EXISTING' && !selectedFileId) || (importTab === 'UPLOAD' && !fileToUpload) || isImporting}
874
+ className="px-4 py-2 text-sm font-bold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg disabled:opacity-50 shadow-md flex items-center gap-2"
875
+ >
876
+ {isImporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
877
+ {isImporting ? 'Processing...' : 'Sync & Parse'}
878
+ </button>
879
+ </div>
880
+ </div>
881
+ </div>
882
+ </div>
883
+ )}
884
+
885
+ {/* Add Item Modal */}
886
+ {isModalOpen && (
887
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
888
+ <div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
889
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50/50">
890
+ <div className="flex items-center gap-2">
891
+ <h3 className="font-semibold text-slate-800">Add New BOQ Item</h3>
892
+ {isSuggesting && (
893
+ <div className="flex items-center gap-1.5 px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded animate-pulse">
894
+ <Sparkles className="w-2.5 h-2.5" />
895
+ AI Estimating...
896
+ </div>
897
+ )}
898
+ </div>
899
+ <button onClick={() => { setIsModalOpen(false); resetForm(); }} className="text-slate-400 hover:text-slate-600 transition-colors">
900
+ <X className="w-5 h-5" />
901
+ </button>
902
+ </div>
903
+
904
+ <form onSubmit={handleAddItem} className="p-6 space-y-4 max-h-[80vh] overflow-y-auto">
905
+ <div>
906
+ <label className="block text-sm font-medium text-slate-700 mb-1">Item Description</label>
907
+ <textarea
908
+ required
909
+ rows={2}
910
+ value={description}
911
+ onChange={(e) => setDescription(e.target.value)}
912
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none resize-none text-sm"
913
+ placeholder="e.g., Earth work in cutting and filling of eroded bank"
914
+ />
915
+ </div>
916
+
917
+ <div className="grid grid-cols-2 gap-4">
918
+ <div>
919
+ <label className="block text-sm font-medium text-slate-700 mb-1">Unit</label>
920
+ <select
921
+ value={unit}
922
+ onChange={(e) => setUnit(e.target.value as Unit)}
923
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white"
924
+ >
925
+ {Object.values(Unit).map(u => <option key={u} value={u}>{u}</option>)}
926
+ </select>
927
+ </div>
928
+ <div>
929
+ <label className="block text-sm font-medium text-slate-700 mb-1">Priority</label>
930
+ <select
931
+ value={itemPriority}
932
+ onChange={(e) => setItemPriority(e.target.value as Priority)}
933
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white"
934
+ >
935
+ <option value="LOW">Low</option>
936
+ <option value="MEDIUM">Medium</option>
937
+ <option value="HIGH">High</option>
938
+ </select>
939
+ </div>
940
+ </div>
941
+
942
+ <div className="grid grid-cols-2 gap-4">
943
+ <div>
944
+ <label className="block text-sm font-medium text-slate-700 mb-1">Selling Rate (৳)</label>
945
+ <input
946
+ type="number"
947
+ min="0"
948
+ step="0.01"
949
+ required
950
+ value={rate}
951
+ onChange={(e) => setRate(e.target.value)}
952
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono"
953
+ placeholder="0.00"
954
+ />
955
+ </div>
956
+ <div>
957
+ <label className="block text-sm font-medium text-slate-700 mb-1">Planned Quantity</label>
958
+ <input
959
+ type="number"
960
+ min="0"
961
+ step="0.01"
962
+ required
963
+ value={plannedQty}
964
+ onChange={(e) => setPlannedQty(e.target.value)}
965
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono"
966
+ placeholder="0.00"
967
+ />
968
+ </div>
969
+ </div>
970
+
971
+ <div className="bg-slate-50 rounded-xl p-4 border border-slate-200 space-y-4">
972
+ <div className="flex items-center justify-between">
973
+ <div className="flex flex-col">
974
+ <h4 className="text-xs font-bold text-slate-500 uppercase">Internal Budget Components</h4>
975
+ {aiAppliedFields && (
976
+ <span className="text-[10px] text-indigo-600 font-bold flex items-center gap-1 animate-in fade-in slide-in-from-left-2">
977
+ <CheckCircle2 className="w-3 h-3" /> Populated by AI
978
+ </span>
979
+ )}
980
+ </div>
981
+ {description && (
982
+ <button
983
+ type="button"
984
+ onClick={handleSuggestCost}
985
+ disabled={isSuggesting}
986
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold shadow-md hover:bg-indigo-700 transition-all disabled:opacity-50 active:scale-95"
987
+ >
988
+ {isSuggesting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
989
+ AI Suggest Cost
990
+ </button>
991
+ )}
992
+ </div>
993
+
994
+ <div className="grid grid-cols-2 gap-4">
995
+ <div>
996
+ <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Material</label>
997
+ <input
998
+ type="number"
999
+ value={plannedMat}
1000
+ onChange={(e) => { setPlannedMat(e.target.value); setAiAppliedFields(false); }}
1001
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono transition-all ${aiAppliedFields ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300 bg-white'}`}
1002
+ />
1003
+ </div>
1004
+ <div>
1005
+ <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Labor</label>
1006
+ <input
1007
+ type="number"
1008
+ value={plannedLab}
1009
+ onChange={(e) => { setPlannedLab(e.target.value); setAiAppliedFields(false); }}
1010
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono transition-all ${aiAppliedFields ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300 bg-white'}`}
1011
+ />
1012
+ </div>
1013
+ <div>
1014
+ <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Equipment</label>
1015
+ <input
1016
+ type="number"
1017
+ value={plannedEqp}
1018
+ onChange={(e) => { setPlannedEqp(e.target.value); setAiAppliedFields(false); }}
1019
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono transition-all ${aiAppliedFields ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300 bg-white'}`}
1020
+ />
1021
+ </div>
1022
+ <div>
1023
+ <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Overhead</label>
1024
+ <input
1025
+ type="number"
1026
+ value={plannedOH}
1027
+ onChange={(e) => { setPlannedOH(e.target.value); setAiAppliedFields(false); }}
1028
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-mono transition-all ${aiAppliedFields ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300 bg-white'}`}
1029
+ />
1030
+ </div>
1031
+ </div>
1032
+
1033
+ <div className="pt-2 border-t border-slate-200">
1034
+ <div className="flex justify-between items-center">
1035
+ <span className="text-sm font-bold text-slate-700">Planned Unit Cost:</span>
1036
+ <span className="text-xl font-mono font-black text-indigo-700">৳{Number(plannedUnitCost).toLocaleString()}</span>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+
1041
+ <div className="pt-4 flex justify-end gap-3 border-t border-slate-100">
1042
+ <button
1043
+ type="button"
1044
+ onClick={() => { setIsModalOpen(false); resetForm(); }}
1045
+ className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
1046
+ >
1047
+ Cancel
1048
+ </button>
1049
+ <button
1050
+ type="submit"
1051
+ className="px-6 py-2 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-all shadow-md active:scale-95"
1052
+ >
1053
+ Create BOQ Item
1054
+ </button>
1055
+ </div>
1056
+ </form>
1057
+ </div>
1058
+ </div>
1059
+ )}
1060
+ </div>
1061
+ );
1062
+ };
1063
+
1064
+ export default MasterControl;
components/MemberManager.tsx ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { ProjectMember, User, UserRole } from '../types';
3
+ import {
4
+ UserPlus,
5
+ Shield,
6
+ Trash2,
7
+ Search,
8
+ CheckCircle2,
9
+ XCircle,
10
+ User as UserIcon,
11
+ Crown
12
+ } from 'lucide-react';
13
+ import { useLocalCollection } from '../hooks/useLocalCollection';
14
+
15
+ interface MemberManagerProps {
16
+ projectId: string;
17
+ ownerUid: string;
18
+ currentUserUid: string;
19
+ }
20
+
21
+ const MemberManager: React.FC<MemberManagerProps> = ({ projectId, ownerUid, currentUserUid }) => {
22
+ const { data: members, add, update, remove } = useLocalCollection<ProjectMember & { id: string }>(`members_${projectId}`);
23
+ const [searchEmail, setSearchEmail] = useState('');
24
+ const [searchResult, setSearchResult] = useState<User | null>(null);
25
+ const [searchError, setSearchError] = useState<string | null>(null);
26
+ const [allUsers, setAllUsers] = useState<User[]>([]);
27
+
28
+ const isOwner = currentUserUid === ownerUid;
29
+
30
+ useEffect(() => {
31
+ // Fetch all users for search (ideally this is a backend search API, but we'll fetch entire mock users collection for now)
32
+ fetch('/api/collections/users')
33
+ .then(res => res.json())
34
+ .then(result => setAllUsers(result || []))
35
+ .catch(console.error);
36
+ }, []);
37
+
38
+ const handleSearch = async (e: React.FormEvent) => {
39
+ e.preventDefault();
40
+ if (!searchEmail.trim()) return;
41
+
42
+ setSearchError(null);
43
+ setSearchResult(null);
44
+
45
+ // Mock search against users collection
46
+ if (searchEmail === 'mock@example.com') {
47
+ setSearchResult({ uid: 'mock-1', name: 'Mock User', role: 'ENGINEER', avatar: '', email: 'mock@example.com' });
48
+ return;
49
+ }
50
+
51
+ const found = allUsers.find(u => u.email === searchEmail || u.name?.toLowerCase() === searchEmail.toLowerCase());
52
+ if (found) {
53
+ if (members.some(m => m.uid === found.uid)) {
54
+ setSearchError('This user is already a member of the project.');
55
+ } else {
56
+ setSearchResult(found);
57
+ }
58
+ } else {
59
+ setSearchError('User not found. For offline simulation, type "mock@example.com"');
60
+ }
61
+ };
62
+
63
+ const handleAddMember = async (user: User) => {
64
+ if (!user.uid) return;
65
+
66
+ // We append the standard useLocalCollection ID to map it uniquely
67
+ const memberData: ProjectMember & { id: string } = {
68
+ id: user.uid, // Map uid to the collection's string ID
69
+ uid: user.uid,
70
+ name: user.name,
71
+ role: user.role,
72
+ avatar: user.avatar || null,
73
+ joinedAt: new Date().toISOString()
74
+ };
75
+
76
+ add(memberData);
77
+
78
+ setSearchResult(null);
79
+ setSearchEmail('');
80
+ };
81
+
82
+ const handleUpdateRole = async (memberUid: string, newRole: UserRole) => {
83
+ // Map using memberUid as id
84
+ update(memberUid, { role: newRole });
85
+ };
86
+
87
+ const handleRemoveMember = async (memberUid: string) => {
88
+ if (memberUid === ownerUid) return; // Cannot remove owner
89
+ if (!window.confirm('Are you sure you want to remove this member from the project?')) return;
90
+
91
+ remove(memberUid);
92
+ };
93
+
94
+ return (
95
+ <div className="space-y-6">
96
+ {/* Add Member Section */}
97
+ {isOwner && (
98
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
99
+ <h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
100
+ <UserPlus className="w-5 h-5 text-blue-600" />
101
+ Add Team Member
102
+ </h3>
103
+ <form onSubmit={handleSearch} className="flex gap-3">
104
+ <div className="relative flex-1">
105
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
106
+ <input
107
+ type="email"
108
+ placeholder="Enter user email..."
109
+ value={searchEmail}
110
+ onChange={(e) => setSearchEmail(e.target.value)}
111
+ className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
112
+ />
113
+ </div>
114
+ <button
115
+ type="submit"
116
+ disabled={!searchEmail.trim()}
117
+ className="px-6 py-2.5 bg-blue-600 text-white rounded-xl font-bold text-sm hover:bg-blue-700 transition-all disabled:opacity-50 flex items-center gap-2"
118
+ >
119
+ <Search className="w-4 h-4" />
120
+ Search
121
+ </button>
122
+ </form>
123
+
124
+ {searchError && (
125
+ <div className="mt-4 p-3 bg-red-50 border border-red-100 rounded-xl flex items-center gap-2 text-red-600 text-sm">
126
+ <XCircle className="w-4 h-4" />
127
+ {searchError}
128
+ </div>
129
+ )}
130
+
131
+ {searchResult && (
132
+ <div className="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-xl flex items-center justify-between">
133
+ <div className="flex items-center gap-3">
134
+ {searchResult.avatar ? (
135
+ <img src={searchResult.avatar} alt="" className="w-10 h-10 rounded-full border-2 border-white" />
136
+ ) : (
137
+ <div className="w-10 h-10 bg-blue-200 text-blue-700 rounded-full flex items-center justify-center font-bold">
138
+ {searchResult.name.charAt(0)}
139
+ </div>
140
+ )}
141
+ <div>
142
+ <p className="text-sm font-bold text-slate-800">{searchResult.name}</p>
143
+ <p className="text-xs text-slate-500">{searchResult.email}</p>
144
+ </div>
145
+ </div>
146
+ <button
147
+ onClick={() => handleAddMember(searchResult)}
148
+ className="px-4 py-2 bg-blue-600 text-white rounded-lg font-bold text-xs hover:bg-blue-700 transition-all flex items-center gap-2"
149
+ >
150
+ <UserPlus className="w-3 h-3" />
151
+ Add to Project
152
+ </button>
153
+ </div>
154
+ )}
155
+ </div>
156
+ )}
157
+
158
+ {/* Members List */}
159
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
160
+ <div className="px-6 py-4 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
161
+ <h3 className="font-bold text-slate-800 flex items-center gap-2">
162
+ <Shield className="w-5 h-5 text-indigo-600" />
163
+ Project Team
164
+ </h3>
165
+ <span className="text-xs font-bold text-slate-500 bg-slate-200 px-2 py-0.5 rounded-full">
166
+ {members.length} Members
167
+ </span>
168
+ </div>
169
+ <div className="divide-y divide-slate-100">
170
+ {members.map((member) => (
171
+ <div key={member.uid} className="p-6 flex items-center justify-between group hover:bg-slate-50 transition-all">
172
+ <div className="flex items-center gap-4">
173
+ <div className="relative">
174
+ {member.avatar ? (
175
+ <img src={member.avatar} alt="" className="w-12 h-12 rounded-full border-2 border-white shadow-sm" />
176
+ ) : (
177
+ <div className="w-12 h-12 bg-slate-100 text-slate-600 rounded-full flex items-center justify-center font-bold text-lg">
178
+ {member.name.charAt(0)}
179
+ </div>
180
+ )}
181
+ {member.uid === ownerUid && (
182
+ <div className="absolute -top-1 -right-1 bg-amber-400 text-white p-1 rounded-full border-2 border-white shadow-sm">
183
+ <Crown className="w-3 h-3" />
184
+ </div>
185
+ )}
186
+ </div>
187
+ <div>
188
+ <div className="flex items-center gap-2">
189
+ <p className="font-bold text-slate-800">{member.name}</p>
190
+ {member.uid === currentUserUid && (
191
+ <span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded uppercase">You</span>
192
+ )}
193
+ </div>
194
+ <p className="text-xs text-slate-500 mt-0.5">Joined {new Date(member.joinedAt).toLocaleDateString()}</p>
195
+ </div>
196
+ </div>
197
+
198
+ <div className="flex items-center gap-6">
199
+ <div className="flex flex-col items-end">
200
+ {isOwner && member.uid !== ownerUid ? (
201
+ <select
202
+ value={member.role}
203
+ onChange={(e) => handleUpdateRole(member.uid, e.target.value as UserRole)}
204
+ className="text-xs font-bold text-blue-600 bg-blue-50 px-3 py-1.5 rounded-lg border-none outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
205
+ >
206
+ <option value="DIRECTOR">Project Director</option>
207
+ <option value="MANAGER">Project Manager</option>
208
+ <option value="ENGINEER">Site Engineer</option>
209
+ <option value="ACCOUNTANT">Accountant</option>
210
+ </select>
211
+ ) : (
212
+ <span className="text-xs font-bold text-slate-600 bg-slate-100 px-3 py-1.5 rounded-lg">
213
+ {member.role}
214
+ </span>
215
+ )}
216
+ </div>
217
+
218
+ {isOwner && member.uid !== ownerUid && (
219
+ <button
220
+ onClick={() => handleRemoveMember(member.uid)}
221
+ className="p-2 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all"
222
+ title="Remove Member"
223
+ >
224
+ <Trash2 className="w-5 h-5" />
225
+ </button>
226
+ )}
227
+ </div>
228
+ </div>
229
+ ))}
230
+ </div>
231
+ </div>
232
+ </div>
233
+ );
234
+ };
235
+
236
+ export default MemberManager;
components/PhotoLogs.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { PhotoLog, User } from '../types';
4
+ import {
5
+ Camera,
6
+ Search,
7
+ Filter,
8
+ Plus,
9
+ MoreVertical,
10
+ MapPin,
11
+ Calendar,
12
+ Tag,
13
+ Download,
14
+ Share2,
15
+ Maximize2,
16
+ User as UserIcon,
17
+ ChevronRight
18
+ } from 'lucide-react';
19
+
20
+ interface PhotoLogsProps {
21
+ photoLogs: PhotoLog[];
22
+ users: User[];
23
+ }
24
+
25
+ const PhotoLogs: React.FC<PhotoLogsProps> = ({ photoLogs, users }) => {
26
+ const [searchQuery, setSearchQuery] = React.useState('');
27
+ const [selectedPhoto, setSelectedPhoto] = React.useState<PhotoLog | null>(null);
28
+
29
+ const filteredPhotos = photoLogs.filter(p =>
30
+ p.caption.toLowerCase().includes(searchQuery.toLowerCase()) ||
31
+ p.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
32
+ p.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()))
33
+ );
34
+
35
+ const getUploaderName = (uid: string) => {
36
+ return users.find(u => u.uid === uid)?.name || 'Unknown User';
37
+ };
38
+
39
+ return (
40
+ <div className="space-y-6">
41
+ {/* Header & Filters */}
42
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
43
+ <div className="flex items-center gap-4 flex-1 max-w-md">
44
+ <div className="relative flex-1">
45
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
46
+ <input
47
+ type="text"
48
+ placeholder="Search photos, locations, or tags..."
49
+ value={searchQuery}
50
+ onChange={(e) => setSearchQuery(e.target.value)}
51
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
52
+ />
53
+ </div>
54
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
55
+ <Filter className="w-5 h-5" />
56
+ </button>
57
+ </div>
58
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
59
+ <Plus className="w-4 h-4" />
60
+ Upload Photos
61
+ </button>
62
+ </div>
63
+
64
+ {/* Photo Grid */}
65
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
66
+ {filteredPhotos.map(photo => (
67
+ <div key={photo.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden hover:border-blue-300 transition-all group">
68
+ <div className="relative aspect-square overflow-hidden bg-slate-100">
69
+ <img
70
+ src={photo.url}
71
+ alt={photo.caption}
72
+ className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
73
+ referrerPolicy="no-referrer"
74
+ />
75
+ <div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-4 gap-2">
76
+ <button
77
+ onClick={() => setSelectedPhoto(photo)}
78
+ className="p-2 bg-white/20 backdrop-blur-md text-white rounded-lg hover:bg-white/40 transition-all"
79
+ >
80
+ <Maximize2 className="w-4 h-4" />
81
+ </button>
82
+ <button className="p-2 bg-white/20 backdrop-blur-md text-white rounded-lg hover:bg-white/40 transition-all">
83
+ <Download className="w-4 h-4" />
84
+ </button>
85
+ <button className="p-2 bg-white/20 backdrop-blur-md text-white rounded-lg hover:bg-white/40 transition-all">
86
+ <Share2 className="w-4 h-4" />
87
+ </button>
88
+ </div>
89
+ </div>
90
+
91
+ <div className="p-4">
92
+ <h4 className="font-bold text-slate-800 text-sm mb-2 line-clamp-1">{photo.caption}</h4>
93
+
94
+ <div className="flex items-center gap-3 mb-3">
95
+ <div className="flex items-center gap-1 text-[10px] text-slate-500 font-medium">
96
+ <MapPin className="w-3 h-3" />
97
+ {photo.location}
98
+ </div>
99
+ <div className="flex items-center gap-1 text-[10px] text-slate-500 font-medium">
100
+ <Calendar className="w-3 h-3" />
101
+ {new Date(photo.createdAt).toLocaleDateString()}
102
+ </div>
103
+ </div>
104
+
105
+ <div className="flex flex-wrap gap-1.5 mb-4">
106
+ {photo.tags.map((tag, idx) => (
107
+ <span key={idx} className="px-2 py-0.5 bg-slate-100 text-slate-500 text-[9px] font-bold rounded-full border border-slate-200">
108
+ #{tag}
109
+ </span>
110
+ ))}
111
+ </div>
112
+
113
+ <div className="flex items-center justify-between pt-3 border-t border-slate-100">
114
+ <div className="flex items-center gap-2">
115
+ <div className="w-6 h-6 bg-slate-100 rounded-full flex items-center justify-center">
116
+ <UserIcon className="w-3 h-3 text-slate-500" />
117
+ </div>
118
+ <span className="text-[10px] font-bold text-slate-500 uppercase">{getUploaderName(photo.uploadedBy)}</span>
119
+ </div>
120
+ <button className="p-1 text-slate-400 hover:text-slate-600 rounded-lg transition-all">
121
+ <MoreVertical className="w-4 h-4" />
122
+ </button>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ ))}
127
+ </div>
128
+
129
+ {/* Photo Modal */}
130
+ {selectedPhoto && (
131
+ <div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/90 backdrop-blur-sm">
132
+ <div className="relative w-full max-w-5xl aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl">
133
+ <img
134
+ src={selectedPhoto.url}
135
+ alt={selectedPhoto.caption}
136
+ className="w-full h-full object-contain"
137
+ referrerPolicy="no-referrer"
138
+ />
139
+ <button
140
+ onClick={() => setSelectedPhoto(null)}
141
+ className="absolute top-4 right-4 p-2 bg-white/10 backdrop-blur-md text-white rounded-full hover:bg-white/20 transition-all"
142
+ >
143
+ <Plus className="w-6 h-6 rotate-45" />
144
+ </button>
145
+ <div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 to-transparent text-white">
146
+ <h3 className="text-xl font-bold mb-2">{selectedPhoto.caption}</h3>
147
+ <p className="text-sm text-slate-300">{selectedPhoto.location} • {new Date(selectedPhoto.createdAt).toLocaleString()}</p>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ };
155
+
156
+ export default PhotoLogs;
components/Procurement.tsx ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Material, PurchaseOrder, Unit } from '../types';
4
+ import {
5
+ Plus,
6
+ Search,
7
+ Filter,
8
+ ShoppingCart,
9
+ Package,
10
+ Truck,
11
+ CheckCircle2,
12
+ Clock,
13
+ AlertTriangle,
14
+ MoreVertical,
15
+ ChevronRight
16
+ } from 'lucide-react';
17
+
18
+ interface ProcurementProps {
19
+ materials: Material[];
20
+ purchaseOrders: PurchaseOrder[];
21
+ }
22
+
23
+ const Procurement: React.FC<ProcurementProps> = ({ materials, purchaseOrders }) => {
24
+ const [searchQuery, setSearchQuery] = React.useState('');
25
+ const [activeTab, setActiveTab] = React.useState<'INVENTORY' | 'ORDERS'>('INVENTORY');
26
+
27
+ const filteredMaterials = materials.filter(m => m.name.toLowerCase().includes(searchQuery.toLowerCase()));
28
+ const filteredOrders = purchaseOrders.filter(o => o.vendorName.toLowerCase().includes(searchQuery.toLowerCase()));
29
+
30
+ const getStatusColor = (status: string) => {
31
+ switch(status) {
32
+ case 'DELIVERED': return 'text-emerald-600 bg-emerald-50 border-emerald-100';
33
+ case 'SENT': return 'text-blue-600 bg-blue-50 border-blue-100';
34
+ case 'DRAFT': return 'text-slate-600 bg-slate-50 border-slate-100';
35
+ case 'CANCELLED': return 'text-red-600 bg-red-50 border-red-100';
36
+ default: return 'text-slate-600 bg-slate-50 border-slate-100';
37
+ }
38
+ };
39
+
40
+ return (
41
+ <div className="space-y-6">
42
+ {/* Header & Tabs */}
43
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
44
+ <div className="flex bg-slate-100 p-1 rounded-xl">
45
+ <button
46
+ onClick={() => setActiveTab('INVENTORY')}
47
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold text-sm transition-all ${activeTab === 'INVENTORY' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
48
+ >
49
+ <Package className="w-4 h-4" />
50
+ Inventory
51
+ </button>
52
+ <button
53
+ onClick={() => setActiveTab('ORDERS')}
54
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold text-sm transition-all ${activeTab === 'ORDERS' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
55
+ >
56
+ <ShoppingCart className="w-4 h-4" />
57
+ Purchase Orders
58
+ </button>
59
+ </div>
60
+
61
+ <div className="flex items-center gap-3 flex-1 max-w-md">
62
+ <div className="relative flex-1">
63
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
64
+ <input
65
+ type="text"
66
+ placeholder={`Search ${activeTab.toLowerCase()}...`}
67
+ value={searchQuery}
68
+ onChange={(e) => setSearchQuery(e.target.value)}
69
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
70
+ />
71
+ </div>
72
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
73
+ <Plus className="w-4 h-4" />
74
+ {activeTab === 'INVENTORY' ? 'Add Material' : 'New Order'}
75
+ </button>
76
+ </div>
77
+ </div>
78
+
79
+ {activeTab === 'INVENTORY' ? (
80
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
81
+ {filteredMaterials.map(material => (
82
+ <div key={material.id} className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group">
83
+ <div className="flex items-start justify-between mb-4">
84
+ <div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center group-hover:bg-blue-600 group-hover:text-white transition-all">
85
+ <Package className="w-6 h-6" />
86
+ </div>
87
+ {material.currentStock < (material.totalReceived * 0.1) && (
88
+ <div className="flex items-center gap-1 text-[10px] font-bold text-red-600 bg-red-50 px-2 py-1 rounded-full border border-red-100 animate-pulse">
89
+ <AlertTriangle className="w-3 h-3" />
90
+ LOW STOCK
91
+ </div>
92
+ )}
93
+ </div>
94
+ <h3 className="font-bold text-slate-800 mb-1">{material.name}</h3>
95
+ <p className="text-xs text-slate-500 mb-4">ID: {material.id}</p>
96
+
97
+ <div className="grid grid-cols-2 gap-4 mb-4">
98
+ <div className="p-3 bg-slate-50 rounded-xl">
99
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Stock</span>
100
+ <p className="text-lg font-bold text-slate-800">{material.currentStock} {material.unit}</p>
101
+ </div>
102
+ <div className="p-3 bg-slate-50 rounded-xl">
103
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Consumed</span>
104
+ <p className="text-lg font-bold text-slate-800">{material.totalConsumed} {material.unit}</p>
105
+ </div>
106
+ </div>
107
+
108
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden mb-4">
109
+ <div
110
+ className={`h-full transition-all ${material.currentStock < (material.totalReceived * 0.1) ? 'bg-red-500' : 'bg-blue-500'}`}
111
+ style={{ width: `${(material.currentStock / material.totalReceived) * 100}%` }}
112
+ />
113
+ </div>
114
+
115
+ <button className="w-full py-2 text-blue-600 font-bold text-xs hover:bg-blue-50 rounded-lg transition-all flex items-center justify-center gap-2">
116
+ View Details
117
+ <ChevronRight className="w-3 h-3" />
118
+ </button>
119
+ </div>
120
+ ))}
121
+ </div>
122
+ ) : (
123
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
124
+ <table className="w-full text-left border-collapse">
125
+ <thead>
126
+ <tr className="bg-slate-50 border-b border-slate-100">
127
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Order ID</th>
128
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Vendor</th>
129
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Amount</th>
130
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Status</th>
131
+ <th className="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-wider">Delivery Date</th>
132
+ <th className="px-6 py-4"></th>
133
+ </tr>
134
+ </thead>
135
+ <tbody className="divide-y divide-slate-100">
136
+ {filteredOrders.map(order => (
137
+ <tr key={order.id} className="hover:bg-slate-50/50 transition-colors group">
138
+ <td className="px-6 py-4">
139
+ <span className="font-bold text-slate-800 text-sm">{order.id}</span>
140
+ </td>
141
+ <td className="px-6 py-4">
142
+ <div className="flex items-center gap-3">
143
+ <div className="w-8 h-8 bg-slate-100 rounded-lg flex items-center justify-center">
144
+ <Truck className="w-4 h-4 text-slate-500" />
145
+ </div>
146
+ <span className="font-medium text-slate-700 text-sm">{order.vendorName}</span>
147
+ </div>
148
+ </td>
149
+ <td className="px-6 py-4 font-bold text-slate-800 text-sm">৳{order.totalAmount.toLocaleString()}</td>
150
+ <td className="px-6 py-4">
151
+ <span className={`px-2.5 py-1 rounded-full text-[10px] font-bold border ${getStatusColor(order.status)}`}>
152
+ {order.status}
153
+ </span>
154
+ </td>
155
+ <td className="px-6 py-4 text-sm text-slate-500">
156
+ {order.expectedDeliveryDate || 'TBD'}
157
+ </td>
158
+ <td className="px-6 py-4 text-right">
159
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
160
+ <MoreVertical className="w-4 h-4" />
161
+ </button>
162
+ </td>
163
+ </tr>
164
+ ))}
165
+ </tbody>
166
+ </table>
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ };
172
+
173
+ export default Procurement;
components/ProjectList.tsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState } from 'react';
3
+ import { ProjectState, UserRole, Priority } from '../types';
4
+ import {
5
+ PlusCircle,
6
+ Building2,
7
+ Calendar,
8
+ ArrowRight,
9
+ Activity,
10
+ DollarSign,
11
+ UserCircle,
12
+ Lock,
13
+ Flag
14
+ } from 'lucide-react';
15
+
16
+ interface ProjectListProps {
17
+ projects: ProjectState[];
18
+ onSelectProject: (projectId: string) => void;
19
+ onCreateProject: (project: Partial<ProjectState>) => void;
20
+ userRole: UserRole;
21
+ onSwitchRole: (role: UserRole) => void;
22
+ }
23
+
24
+ const ProjectList: React.FC<ProjectListProps> = ({ projects, onSelectProject, onCreateProject, userRole, onSwitchRole }) => {
25
+ const [isModalOpen, setIsModalOpen] = useState(false);
26
+ const [newProjectName, setNewProjectName] = useState('');
27
+ const [contractValue, setContractValue] = useState('');
28
+ const [priority, setPriority] = useState<Priority>('MEDIUM');
29
+
30
+ const canCreateProject = userRole === 'DIRECTOR';
31
+
32
+ console.log('Current projects IDs:', projects.map(p => p.id));
33
+
34
+ const handleCreate = (e: React.FormEvent) => {
35
+ e.preventDefault();
36
+ onCreateProject({
37
+ name: newProjectName,
38
+ contractValue: Number(contractValue),
39
+ priority,
40
+ startDate: new Date().toISOString().split('T')[0],
41
+ endDate: new Date().toISOString().split('T')[0],
42
+ status: 'ACTIVE',
43
+ boq: [],
44
+ dprs: [],
45
+ bills: [],
46
+ liabilities: [],
47
+ documents: []
48
+ });
49
+ setIsModalOpen(false);
50
+ setNewProjectName('');
51
+ setContractValue('');
52
+ setPriority('MEDIUM');
53
+ };
54
+
55
+ const getRoleLabel = (role: UserRole) => {
56
+ switch(role) {
57
+ case 'DIRECTOR': return 'Project Director';
58
+ case 'MANAGER': return 'Project Manager';
59
+ case 'ENGINEER': return 'Site Engineer';
60
+ case 'ACCOUNTANT': return 'Accountant';
61
+ default: return role;
62
+ }
63
+ };
64
+
65
+ const getPriorityColor = (p: Priority) => {
66
+ switch(p) {
67
+ case 'HIGH': return 'bg-red-50 text-red-700 border-red-200';
68
+ case 'MEDIUM': return 'bg-amber-50 text-amber-700 border-amber-200';
69
+ case 'LOW': return 'bg-blue-50 text-blue-700 border-blue-200';
70
+ default: return 'bg-slate-50 text-slate-700 border-slate-200';
71
+ }
72
+ };
73
+
74
+ return (
75
+ <div className="p-6 md:p-10 space-y-8 max-w-7xl mx-auto">
76
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
77
+ <div>
78
+ <h1 className="text-3xl font-bold text-slate-800">My Projects</h1>
79
+ <p className="text-slate-500 mt-1">Manage your construction portfolio across different sites.</p>
80
+ </div>
81
+
82
+ <div className="flex items-center gap-4">
83
+ <div className="flex items-center gap-2 px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium">
84
+ <UserCircle className="w-5 h-5 text-slate-400" />
85
+ <span>{getRoleLabel(userRole)}</span>
86
+ </div>
87
+
88
+ {canCreateProject ? (
89
+ <button
90
+ onClick={() => setIsModalOpen(true)}
91
+ className="flex items-center gap-2 bg-blue-600 text-white px-5 py-2.5 rounded-lg hover:bg-blue-700 transition-colors shadow-sm font-medium"
92
+ >
93
+ <PlusCircle className="w-5 h-5" />
94
+ Create New Project
95
+ </button>
96
+ ) : (
97
+ <div className="flex items-center gap-2 text-slate-400 bg-slate-100 px-4 py-2.5 rounded-lg text-sm font-medium">
98
+ <Lock className="w-4 h-4" />
99
+ Create Disabled
100
+ </div>
101
+ )}
102
+ </div>
103
+ </div>
104
+
105
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
106
+ {(() => {
107
+ const seen = new Set();
108
+ const duplicates = projects.filter(p => {
109
+ if (seen.has(p.id)) return true;
110
+ seen.add(p.id);
111
+ return false;
112
+ });
113
+ if (duplicates.length > 0) {
114
+ console.warn('Duplicate project IDs found:', duplicates.map(d => d.id));
115
+ }
116
+ return null;
117
+ })()}
118
+ {projects.map((project, index) => (
119
+ <div
120
+ key={`${project.id}-${index}`}
121
+ onClick={() => onSelectProject(project.id)}
122
+ className="group bg-white rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all cursor-pointer overflow-hidden relative"
123
+ >
124
+ <div className={`absolute top-0 left-0 w-1 h-full ${project.status === 'ACTIVE' ? 'bg-emerald-500' : 'bg-slate-300'}`}></div>
125
+
126
+ <div className="p-6">
127
+ <div className="flex justify-between items-start mb-4">
128
+ <div className="p-3 bg-blue-50 text-blue-600 rounded-lg group-hover:bg-blue-600 group-hover:text-white transition-colors">
129
+ <Building2 className="w-6 h-6" />
130
+ </div>
131
+ <div className="flex flex-col items-end gap-1.5">
132
+ <div className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold border uppercase tracking-wider ${
133
+ project.status === 'ACTIVE'
134
+ ? 'bg-emerald-50 text-emerald-700 border-emerald-200'
135
+ : 'bg-slate-100 text-slate-600 border-slate-200'
136
+ }`}>
137
+ {project.status}
138
+ </div>
139
+ <div className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold border uppercase tracking-wider flex items-center gap-1 ${getPriorityColor(project.priority)}`}>
140
+ <Flag className="w-2.5 h-2.5" />
141
+ {project.priority}
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ <h3 className="text-lg font-bold text-slate-800 mb-2 line-clamp-2 min-h-[3.5rem]">
147
+ {project.name}
148
+ </h3>
149
+
150
+ <div className="space-y-3 text-sm text-slate-600">
151
+ <div className="flex items-center gap-2">
152
+ <DollarSign className="w-4 h-4 text-slate-400" />
153
+ <span>৳{(project.contractValue / 1000000).toFixed(2)} Million</span>
154
+ </div>
155
+ <div className="flex items-center gap-2">
156
+ <Calendar className="w-4 h-4 text-slate-400" />
157
+ <span>{project.startDate} to {project.endDate}</span>
158
+ </div>
159
+ <div className="flex items-center gap-2">
160
+ <Activity className="w-4 h-4 text-slate-400" />
161
+ <span>{project.boq.length} BOQ Items</span>
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <div className="px-6 py-4 bg-slate-50 border-t border-slate-200 flex justify-between items-center group-hover:bg-blue-50 transition-colors">
167
+ <span className="text-sm font-medium text-slate-600 group-hover:text-blue-700">Open Dashboard</span>
168
+ <ArrowRight className="w-4 h-4 text-slate-400 group-hover:text-blue-700 transform group-hover:translate-x-1 transition-all" />
169
+ </div>
170
+ </div>
171
+ ))}
172
+
173
+ {projects.length === 0 && (
174
+ <div className="col-span-full py-20 text-center bg-slate-50 rounded-xl border-2 border-dashed border-slate-300">
175
+ <Building2 className="w-12 h-12 text-slate-400 mx-auto mb-4" />
176
+ <h3 className="text-lg font-medium text-slate-700">No projects yet</h3>
177
+ <p className="text-slate-500 mb-6">Create your first construction project to get started.</p>
178
+ {canCreateProject && (
179
+ <button
180
+ onClick={() => setIsModalOpen(true)}
181
+ className="text-blue-600 font-medium hover:underline"
182
+ >
183
+ Create Project
184
+ </button>
185
+ )}
186
+ </div>
187
+ )}
188
+ </div>
189
+
190
+ {isModalOpen && (
191
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
192
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden">
193
+ <div className="px-6 py-4 border-b border-slate-200">
194
+ <h3 className="font-semibold text-slate-800">New Project</h3>
195
+ </div>
196
+ <form onSubmit={handleCreate} className="p-6 space-y-4">
197
+ <div>
198
+ <label className="block text-sm font-medium text-slate-700 mb-1">Project Name</label>
199
+ <input
200
+ type="text"
201
+ required
202
+ value={newProjectName}
203
+ onChange={(e) => setNewProjectName(e.target.value)}
204
+ placeholder="e.g., Bridge Construction at..."
205
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
206
+ />
207
+ </div>
208
+ <div className="grid grid-cols-2 gap-4">
209
+ <div>
210
+ <label className="block text-sm font-medium text-slate-700 mb-1">Priority</label>
211
+ <select
212
+ value={priority}
213
+ onChange={(e) => setPriority(e.target.value as Priority)}
214
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none bg-white text-sm"
215
+ >
216
+ <option value="LOW">Low</option>
217
+ <option value="MEDIUM">Medium</option>
218
+ <option value="HIGH">High</option>
219
+ </select>
220
+ </div>
221
+ <div>
222
+ <label className="block text-sm font-medium text-slate-700 mb-1">Contract Value (৳)</label>
223
+ <input
224
+ type="number"
225
+ required
226
+ value={contractValue}
227
+ onChange={(e) => setContractValue(e.target.value)}
228
+ placeholder="0"
229
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
230
+ />
231
+ </div>
232
+ </div>
233
+ <div className="pt-4 flex justify-end gap-3">
234
+ <button
235
+ type="button"
236
+ onClick={() => setIsModalOpen(false)}
237
+ className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
238
+ >
239
+ Cancel
240
+ </button>
241
+ <button
242
+ type="submit"
243
+ className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
244
+ >
245
+ Create Project
246
+ </button>
247
+ </div>
248
+ </form>
249
+ </div>
250
+ </div>
251
+ )}
252
+ </div>
253
+ );
254
+ };
255
+
256
+ export default ProjectList;
components/QCSafety.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { QualityCheck, SafetyCheck, User } from '../types';
4
+ import {
5
+ ShieldCheck,
6
+ CheckCircle2,
7
+ XCircle,
8
+ Clock,
9
+ Plus,
10
+ Search,
11
+ Filter,
12
+ AlertTriangle,
13
+ Camera,
14
+ User as UserIcon,
15
+ ChevronRight,
16
+ MoreVertical
17
+ } from 'lucide-react';
18
+
19
+ interface QCSafetyProps {
20
+ qualityChecks: QualityCheck[];
21
+ safetyChecks: SafetyCheck[];
22
+ users: User[];
23
+ }
24
+
25
+ const QCSafety: React.FC<QCSafetyProps> = ({ qualityChecks, safetyChecks, users }) => {
26
+ const [activeTab, setActiveTab] = React.useState<'QUALITY' | 'SAFETY'>('QUALITY');
27
+
28
+ const getInspectorName = (uid: string) => {
29
+ return users.find(u => u.uid === uid)?.name || 'Unknown Inspector';
30
+ };
31
+
32
+ return (
33
+ <div className="space-y-6">
34
+ {/* Header & Tabs */}
35
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
36
+ <div className="flex bg-slate-100 p-1 rounded-xl">
37
+ <button
38
+ onClick={() => setActiveTab('QUALITY')}
39
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold text-sm transition-all ${activeTab === 'QUALITY' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
40
+ >
41
+ <CheckCircle2 className="w-4 h-4" />
42
+ Quality Checks
43
+ </button>
44
+ <button
45
+ onClick={() => setActiveTab('SAFETY')}
46
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold text-sm transition-all ${activeTab === 'SAFETY' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
47
+ >
48
+ <ShieldCheck className="w-4 h-4" />
49
+ Safety Audits
50
+ </button>
51
+ </div>
52
+
53
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
54
+ <Plus className="w-4 h-4" />
55
+ {activeTab === 'QUALITY' ? 'New Inspection' : 'New Audit'}
56
+ </button>
57
+ </div>
58
+
59
+ {activeTab === 'QUALITY' ? (
60
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
61
+ {qualityChecks.map(check => (
62
+ <div key={check.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden hover:border-blue-300 transition-all group">
63
+ <div className="p-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
64
+ <div className="flex items-center gap-4">
65
+ <div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
66
+ check.status === 'PASSED' ? 'bg-emerald-50 text-emerald-600' :
67
+ check.status === 'FAILED' ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-600'
68
+ }`}>
69
+ {check.status === 'PASSED' ? <CheckCircle2 className="w-6 h-6" /> :
70
+ check.status === 'FAILED' ? <XCircle className="w-6 h-6" /> : <Clock className="w-6 h-6" />}
71
+ </div>
72
+ <div>
73
+ <h3 className="font-bold text-slate-800">{check.title}</h3>
74
+ <p className="text-xs text-slate-500">{check.location} • {new Date(check.date).toLocaleDateString()}</p>
75
+ </div>
76
+ </div>
77
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-white rounded-lg transition-all">
78
+ <MoreVertical className="w-4 h-4" />
79
+ </button>
80
+ </div>
81
+
82
+ <div className="p-6 space-y-4">
83
+ <div className="space-y-3">
84
+ {check.items.map((item, idx) => (
85
+ <div key={idx} className="flex items-start gap-3 p-3 bg-slate-50 rounded-xl">
86
+ {item.isOk ? (
87
+ <CheckCircle2 className="w-4 h-4 text-emerald-500 mt-0.5 shrink-0" />
88
+ ) : (
89
+ <XCircle className="w-4 h-4 text-red-500 mt-0.5 shrink-0" />
90
+ )}
91
+ <div>
92
+ <p className="text-sm font-medium text-slate-700">{item.description}</p>
93
+ {item.remarks && <p className="text-[10px] text-slate-500 mt-1 italic">{item.remarks}</p>}
94
+ </div>
95
+ </div>
96
+ ))}
97
+ </div>
98
+
99
+ <div className="flex items-center justify-between pt-4 border-t border-slate-100">
100
+ <div className="flex items-center gap-2">
101
+ <div className="w-6 h-6 bg-slate-200 rounded-full flex items-center justify-center">
102
+ <UserIcon className="w-3 h-3 text-slate-500" />
103
+ </div>
104
+ <span className="text-[10px] font-bold text-slate-500 uppercase">{getInspectorName(check.inspectorUid)}</span>
105
+ </div>
106
+ <div className="flex items-center gap-1 text-[10px] font-bold text-blue-600">
107
+ <Camera className="w-3 h-3" />
108
+ {check.photos?.length || 0} Photos
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ ))}
114
+ </div>
115
+ ) : (
116
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
117
+ {safetyChecks.map(audit => (
118
+ <div key={audit.id} className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group">
119
+ <div className="flex items-start justify-between mb-6">
120
+ <div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
121
+ audit.status === 'SAFE' ? 'bg-emerald-50 text-emerald-600' :
122
+ audit.status === 'CRITICAL' ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-600'
123
+ }`}>
124
+ <ShieldCheck className="w-6 h-6" />
125
+ </div>
126
+ <div className="text-right">
127
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Safety Score</span>
128
+ <p className={`text-2xl font-bold ${
129
+ audit.score >= 90 ? 'text-emerald-600' :
130
+ audit.score >= 70 ? 'text-amber-600' : 'text-red-600'
131
+ }`}>{audit.score}%</p>
132
+ </div>
133
+ </div>
134
+
135
+ <h3 className="font-bold text-slate-800 mb-4">Safety Audit - {new Date(audit.date).toLocaleDateString()}</h3>
136
+
137
+ <div className="space-y-4 mb-6">
138
+ <div>
139
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">Hazards Identified</span>
140
+ <div className="flex flex-wrap gap-2">
141
+ {audit.hazardsIdentified.map((hazard, idx) => (
142
+ <span key={idx} className="px-2 py-1 bg-red-50 text-red-600 text-[10px] font-bold rounded-lg border border-red-100">
143
+ {hazard}
144
+ </span>
145
+ ))}
146
+ </div>
147
+ </div>
148
+ <div>
149
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">Corrective Actions</span>
150
+ <div className="flex flex-wrap gap-2">
151
+ {audit.correctiveActions.map((action, idx) => (
152
+ <span key={idx} className="px-2 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100">
153
+ {action}
154
+ </span>
155
+ ))}
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <div className="flex items-center justify-between pt-4 border-t border-slate-100">
161
+ <div className="flex items-center gap-2">
162
+ <div className="w-6 h-6 bg-slate-200 rounded-full flex items-center justify-center">
163
+ <UserIcon className="w-3 h-3 text-slate-500" />
164
+ </div>
165
+ <span className="text-[10px] font-bold text-slate-500 uppercase">{getInspectorName(audit.inspectorUid)}</span>
166
+ </div>
167
+ <span className={`px-2 py-1 rounded-full text-[10px] font-bold border ${
168
+ audit.status === 'SAFE' ? 'text-emerald-600 bg-emerald-50 border-emerald-100' :
169
+ audit.status === 'CRITICAL' ? 'text-red-600 bg-red-50 border-red-100' : 'text-amber-600 bg-amber-50 border-amber-100'
170
+ }`}>
171
+ {audit.status}
172
+ </span>
173
+ </div>
174
+ </div>
175
+ ))}
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ };
181
+
182
+ export default QCSafety;
components/Reporting.tsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { ProjectState, Bill, DPR, BOQItem } from '../types';
4
+ import {
5
+ FileBarChart,
6
+ Download,
7
+ FileText,
8
+ Calendar,
9
+ DollarSign,
10
+ HardHat,
11
+ CheckCircle2,
12
+ ChevronRight,
13
+ Mail,
14
+ Share2,
15
+ Loader2
16
+ } from 'lucide-react';
17
+
18
+ interface ReportingProps {
19
+ project: ProjectState;
20
+ }
21
+
22
+ const Reporting: React.FC<ReportingProps> = ({ project }) => {
23
+ const [isGenerating, setIsGenerating] = React.useState<string | null>(null);
24
+
25
+ const reports = [
26
+ { id: 'DPR_SUMMARY', title: 'Daily Progress Summary', description: 'Consolidated report of all site activities and labor counts.', icon: HardHat, color: 'bg-blue-50 text-blue-600' },
27
+ { id: 'FINANCIAL_HEALTH', title: 'Financial Health Report', description: 'Budget vs Actual, cash flow, and pending liabilities.', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
28
+ { id: 'BOQ_RECONCILIATION', title: 'BOQ Reconciliation', description: 'Detailed comparison of planned vs executed quantities.', icon: FileText, color: 'bg-amber-50 text-amber-600' },
29
+ { id: 'QC_SAFETY_LOG', title: 'QC & Safety Log', description: 'History of all inspections and safety audits.', icon: CheckCircle2, color: 'bg-red-50 text-red-600' },
30
+ { id: 'STAKEHOLDER_UPDATE', title: 'Executive Stakeholder Update', description: 'High-level summary for directors and clients.', icon: FileBarChart, color: 'bg-purple-50 text-purple-600' },
31
+ ];
32
+
33
+ const handleGenerate = (reportId: string) => {
34
+ setIsGenerating(reportId);
35
+ setTimeout(() => setIsGenerating(null), 2000);
36
+ };
37
+
38
+ return (
39
+ <div className="space-y-6">
40
+ <div className="bg-white p-8 rounded-2xl border border-slate-200 shadow-sm">
41
+ <div className="flex items-start justify-between mb-8">
42
+ <div>
43
+ <h2 className="text-2xl font-bold text-slate-800">Automated Reporting</h2>
44
+ <p className="text-slate-500">Generate and export comprehensive project reports with one click.</p>
45
+ </div>
46
+ <div className="flex items-center gap-3">
47
+ <button className="flex items-center gap-2 text-slate-600 font-bold text-sm hover:bg-slate-50 px-4 py-2 rounded-xl border border-slate-200 transition-all">
48
+ <Calendar className="w-4 h-4" />
49
+ Schedule Reports
50
+ </button>
51
+ </div>
52
+ </div>
53
+
54
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
55
+ {reports.map(report => (
56
+ <div key={report.id} className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group flex items-start gap-6">
57
+ <div className={`w-14 h-14 rounded-2xl flex items-center justify-center shrink-0 ${report.color} group-hover:scale-110 transition-transform`}>
58
+ <report.icon className="w-7 h-7" />
59
+ </div>
60
+ <div className="flex-1">
61
+ <h3 className="font-bold text-slate-800 text-lg mb-1">{report.title}</h3>
62
+ <p className="text-sm text-slate-500 mb-4">{report.description}</p>
63
+
64
+ <div className="flex items-center gap-3">
65
+ <button
66
+ onClick={() => handleGenerate(report.id)}
67
+ disabled={isGenerating === report.id}
68
+ className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl font-bold text-xs hover:bg-slate-800 transition-all disabled:opacity-50"
69
+ >
70
+ {isGenerating === report.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
71
+ {isGenerating === report.id ? 'Generating...' : 'Download PDF'}
72
+ </button>
73
+ <button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all">
74
+ <Mail className="w-4 h-4" />
75
+ </button>
76
+ <button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all">
77
+ <Share2 className="w-4 h-4" />
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ ))}
83
+ </div>
84
+ </div>
85
+
86
+ {/* Recent Reports History */}
87
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
88
+ <div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
89
+ <h3 className="font-bold text-slate-800">Recent Reports History</h3>
90
+ <button className="text-xs font-bold text-blue-600 hover:underline">View All</button>
91
+ </div>
92
+ <div className="divide-y divide-slate-100">
93
+ {[1, 2, 3].map(i => (
94
+ <div key={i} className="px-6 py-4 flex items-center justify-between hover:bg-slate-50/50 transition-colors">
95
+ <div className="flex items-center gap-4">
96
+ <div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
97
+ <FileText className="w-5 h-5 text-slate-500" />
98
+ </div>
99
+ <div>
100
+ <p className="font-bold text-slate-800 text-sm">Monthly_Financial_Summary_Mar_2026.pdf</p>
101
+ <p className="text-[10px] text-slate-500 font-medium uppercase tracking-wider">Generated on Apr 01, 2026 • 2.4 MB</p>
102
+ </div>
103
+ </div>
104
+ <button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all">
105
+ <Download className="w-4 h-4" />
106
+ </button>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ </div>
111
+ </div>
112
+ );
113
+ };
114
+
115
+ export default Reporting;
components/RiskAssessment.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { RiskAssessment } from '../types';
4
+ import { AlertTriangle, ShieldCheck, TrendingUp, TrendingDown, Info, BrainCircuit, Zap } from 'lucide-react';
5
+
6
+ interface RiskAssessmentProps {
7
+ assessment: RiskAssessment;
8
+ }
9
+
10
+ const RiskAssessmentComponent: React.FC<RiskAssessmentProps> = ({ assessment }) => {
11
+ const getImpactColor = (impact: string) => {
12
+ switch (impact) {
13
+ case 'HIGH': return 'text-red-600 bg-red-50 border-red-100';
14
+ case 'MEDIUM': return 'text-amber-600 bg-amber-50 border-amber-100';
15
+ case 'LOW': return 'text-emerald-600 bg-emerald-50 border-emerald-100';
16
+ default: return 'text-slate-600 bg-slate-50 border-slate-100';
17
+ }
18
+ };
19
+
20
+ const getScoreColor = (score: number) => {
21
+ if (score > 70) return 'text-red-600';
22
+ if (score > 40) return 'text-amber-600';
23
+ return 'text-emerald-600';
24
+ };
25
+
26
+ return (
27
+ <div className="space-y-6">
28
+ <div className="bg-slate-900 rounded-2xl p-6 text-white relative overflow-hidden">
29
+ <div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
30
+ <div className="flex items-center gap-4">
31
+ <div className="w-16 h-16 bg-blue-600 rounded-2xl flex items-center justify-center shadow-lg shadow-blue-500/20">
32
+ <BrainCircuit className="w-8 h-8" />
33
+ </div>
34
+ <div>
35
+ <div className="flex items-center gap-2 mb-1">
36
+ <span className="text-[10px] font-bold text-blue-400 uppercase tracking-widest">AI Risk Engine</span>
37
+ <div className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/20 rounded text-[8px] font-bold text-blue-300 border border-blue-500/30">
38
+ <Zap className="w-2 h-2" />
39
+ LIVE
40
+ </div>
41
+ </div>
42
+ <h2 className="text-2xl font-bold">Project Risk Assessment</h2>
43
+ <p className="text-xs text-slate-400">Last updated: {assessment.lastUpdated}</p>
44
+ </div>
45
+ </div>
46
+
47
+ <div className="flex items-center gap-6 bg-white/5 backdrop-blur-md p-4 rounded-2xl border border-white/10">
48
+ <div className="text-center">
49
+ <p className="text-[10px] font-bold text-slate-400 uppercase mb-1">Overall Score</p>
50
+ <p className={`text-3xl font-black ${getScoreColor(assessment.overallRiskScore)}`}>
51
+ {assessment.overallRiskScore}
52
+ </p>
53
+ </div>
54
+ <div className="w-px h-10 bg-white/10"></div>
55
+ <div className="space-y-1">
56
+ <div className="flex items-center gap-2 text-xs font-bold text-emerald-400">
57
+ <TrendingDown className="w-3 h-3" />
58
+ <span>-5% from last week</span>
59
+ </div>
60
+ <p className="text-[10px] text-slate-500">Improving stability</p>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <div className="absolute bottom-0 right-0 w-64 h-64 bg-blue-600/10 blur-[100px] rounded-full -mr-32 -mb-32"></div>
65
+ </div>
66
+
67
+ <div className="grid grid-cols-1 gap-4">
68
+ {assessment.risks.map((risk, idx) => (
69
+ <div key={idx} className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 hover:border-blue-300 transition-all group">
70
+ <div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
71
+ <div className="flex-1">
72
+ <div className="flex items-center gap-3 mb-3">
73
+ <span className={`px-2 py-1 rounded-full border text-[10px] font-bold uppercase tracking-wider ${getImpactColor(risk.impact)}`}>
74
+ {risk.impact} IMPACT
75
+ </span>
76
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{risk.category}</span>
77
+ </div>
78
+ <h3 className="font-bold text-slate-800 text-lg mb-2">{risk.description}</h3>
79
+ <div className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl border border-slate-100">
80
+ <ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
81
+ <div>
82
+ <p className="text-[10px] font-bold text-slate-400 uppercase mb-1">AI Mitigation Strategy</p>
83
+ <p className="text-sm text-slate-700 leading-relaxed font-medium">{risk.mitigation}</p>
84
+ </div>
85
+ </div>
86
+ </div>
87
+
88
+ <div className="w-full md:w-48 space-y-4">
89
+ <div className="space-y-2">
90
+ <div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase">
91
+ <span>Probability</span>
92
+ <span>{(risk.probability * 100).toFixed(0)}%</span>
93
+ </div>
94
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
95
+ <div
96
+ className={`h-full transition-all duration-1000 ${
97
+ risk.probability > 0.7 ? 'bg-red-500' :
98
+ risk.probability > 0.4 ? 'bg-amber-500' : 'bg-emerald-500'
99
+ }`}
100
+ style={{ width: `${risk.probability * 100}%` }}
101
+ />
102
+ </div>
103
+ </div>
104
+ <button className="w-full py-2 text-xs font-bold text-blue-600 border border-blue-200 rounded-xl hover:bg-blue-50 transition-all flex items-center justify-center gap-2">
105
+ <Info className="w-3 h-3" />
106
+ View History
107
+ </button>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default RiskAssessmentComponent;
components/SiteExecution.tsx ADDED
@@ -0,0 +1,1015 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useMemo } from 'react';
3
+ import { ProjectState, ProjectDocument, DPR, UserRole, MaterialConsumption, Unit } from '../types';
4
+ import DocumentManager from './DocumentManager';
5
+ import { MapPin, Users, Calendar, PlusCircle, X, ClipboardCheck, Lock, Sparkles, Loader2, FileText, CheckCircle2, Package, ArrowDownLeft, ArrowUpRight, Edit2, Save, HardHat, BarChart3, AlertCircle, Mic } from 'lucide-react';
6
+ import { extractDPRData } from '../services/localAnalysisService';
7
+ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
8
+
9
+ interface SiteExecutionProps {
10
+ data: ProjectState;
11
+ onAddDocument: (doc: ProjectDocument) => void;
12
+ onAddDPR: (dpr: DPR) => void;
13
+ onReceiveMaterial: (materialId: string, qty: number, rate?: number) => void;
14
+ onUpdatePDRemarks: (type: 'MATERIAL' | 'SUBCONTRACTOR', id: string, remarks: string) => void;
15
+ userRole: UserRole;
16
+ }
17
+
18
+ const SiteExecution: React.FC<SiteExecutionProps> = ({ data, onAddDocument, onAddDPR, onReceiveMaterial, onUpdatePDRemarks, userRole }) => {
19
+ const [isDprModalOpen, setIsDprModalOpen] = useState(false);
20
+ const [isReceiveModalOpen, setIsReceiveModalOpen] = useState(false);
21
+ const [isAiLoading, setIsAiLoading] = useState(false);
22
+ const [aiPopulatedFields, setAiPopulatedFields] = useState<Set<string>>(new Set());
23
+ const [selectedReportId, setSelectedReportId] = useState<string>('');
24
+ const [editingRemarksId, setEditingRemarksId] = useState<string | null>(null);
25
+ const [tempRemarks, setTempRemarks] = useState('');
26
+ const [storeView, setStoreView] = useState<'INVENTORY' | 'HISTORY'>('INVENTORY');
27
+
28
+ const canAddDPR = userRole === 'ENGINEER' || userRole === 'DIRECTOR';
29
+ const canUploadDoc = userRole === 'ENGINEER' || userRole === 'DIRECTOR';
30
+ const canManageStore = userRole === 'ENGINEER' || userRole === 'MANAGER' || userRole === 'DIRECTOR';
31
+ const isDirector = userRole === 'DIRECTOR';
32
+
33
+ // DPR Form State
34
+ const [activityDate, setActivityDate] = useState(new Date().toISOString().split('T')[0]);
35
+ const [activityDesc, setActivityDesc] = useState('');
36
+ const [location, setLocation] = useState('');
37
+ const [laborCount, setLaborCount] = useState(0);
38
+ const [remarks, setRemarks] = useState('');
39
+ const [linkedBoqId, setLinkedBoqId] = useState('');
40
+ const [subContractorId, setSubContractorId] = useState('');
41
+ const [workDoneQty, setWorkDoneQty] = useState(0);
42
+ const [materialsConsumed, setMaterialsConsumed] = useState<MaterialConsumption[]>([]);
43
+
44
+ // Receive Material Form State
45
+ const [receiveMatId, setReceiveMatId] = useState('');
46
+ const [receiveQty, setReceiveQty] = useState('');
47
+ const [receiveRate, setReceiveRate] = useState('');
48
+
49
+ const reportDocs = useMemo(() =>
50
+ data.documents
51
+ .filter(d => d.category === 'REPORT')
52
+ .sort((a, b) => new Date(b.uploadDate).getTime() - new Date(a.uploadDate).getTime()),
53
+ [data.documents]
54
+ );
55
+
56
+ // Prepare Data for Material Chart
57
+ const stockChartData = useMemo(() => {
58
+ return data.materials.map(m => ({
59
+ name: m.name.length > 12 ? m.name.substring(0, 10) + '..' : m.name,
60
+ fullName: m.name,
61
+ Received: m.totalReceived,
62
+ Consumed: m.totalConsumed,
63
+ unit: m.unit
64
+ }));
65
+ }, [data.materials]);
66
+
67
+ // Derive Consumption History Log
68
+ const consumptionHistory = useMemo(() => {
69
+ const history: { id: string; date: string; materialName: string; qty: number; unit: string; activity: string }[] = [];
70
+ data.dprs.forEach(dpr => {
71
+ if (dpr.materialsUsed) {
72
+ dpr.materialsUsed.forEach(usage => {
73
+ const mat = data.materials.find(m => m.id === usage.materialId);
74
+ if (mat) {
75
+ history.push({
76
+ id: `${dpr.id}-${mat.id}`,
77
+ date: dpr.date,
78
+ materialName: mat.name,
79
+ qty: usage.qty,
80
+ unit: mat.unit,
81
+ activity: dpr.activity
82
+ });
83
+ }
84
+ });
85
+ }
86
+ });
87
+ return history.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
88
+ }, [data.dprs, data.materials]);
89
+
90
+ const handleCreateDPR = (e: React.FormEvent) => {
91
+ e.preventDefault();
92
+
93
+ // Validate Material Stock
94
+ const invalidConsumption = materialsConsumed.find(c => {
95
+ const mat = data.materials.find(m => m.id === c.materialId);
96
+ return mat && c.qty > mat.currentStock;
97
+ });
98
+
99
+ if (invalidConsumption) {
100
+ const mat = data.materials.find(m => m.id === invalidConsumption.materialId);
101
+ alert(`Insufficient stock for ${mat?.name}. Available: ${mat?.currentStock} ${mat?.unit}, Requested: ${invalidConsumption.qty} ${mat?.unit}. Please receive material first.`);
102
+ return;
103
+ }
104
+
105
+ let finalDesc = activityDesc;
106
+ if (linkedBoqId && !finalDesc) {
107
+ const boqItem = data.boq.find(b => b.id === linkedBoqId);
108
+ if (boqItem) finalDesc = boqItem.description;
109
+ }
110
+
111
+ const newDPR: DPR = {
112
+ id: `DPR-${Date.now()}`,
113
+ date: activityDate,
114
+ activity: finalDesc || 'Site Activity',
115
+ location: location || 'Site',
116
+ laborCount: Number(laborCount),
117
+ remarks,
118
+ linkedBoqId: linkedBoqId || undefined,
119
+ subContractorId: subContractorId || undefined,
120
+ workDoneQty: Number(workDoneQty) > 0 ? Number(workDoneQty) : undefined,
121
+ materialsUsed: materialsConsumed.filter(m => m.qty > 0)
122
+ };
123
+
124
+ onAddDPR(newDPR);
125
+ setIsDprModalOpen(false);
126
+ resetForm();
127
+ };
128
+
129
+ const handleReceiveSubmit = (e: React.FormEvent) => {
130
+ e.preventDefault();
131
+ if(receiveMatId && receiveQty) {
132
+ onReceiveMaterial(receiveMatId, Number(receiveQty), receiveRate ? Number(receiveRate) : undefined);
133
+ setIsReceiveModalOpen(false);
134
+ setReceiveMatId('');
135
+ setReceiveQty('');
136
+ setReceiveRate('');
137
+ }
138
+ };
139
+
140
+ const resetForm = () => {
141
+ setActivityDesc('');
142
+ setLocation('');
143
+ setLaborCount(0);
144
+ setRemarks('');
145
+ setLinkedBoqId('');
146
+ setSubContractorId('');
147
+ setWorkDoneQty(0);
148
+ setAiPopulatedFields(new Set());
149
+ setSelectedReportId('');
150
+ setMaterialsConsumed([]);
151
+ };
152
+
153
+ // Speech Recognition State
154
+ const [isRecording, setIsRecording] = useState(false);
155
+
156
+ const handleVoiceRecord = () => {
157
+ const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
158
+ if (!SpeechRecognition) {
159
+ alert("Voice recognition is not supported in this browser.");
160
+ return;
161
+ }
162
+
163
+ const recognition = new SpeechRecognition();
164
+ recognition.continuous = false;
165
+ recognition.interimResults = false;
166
+ recognition.lang = 'en-US';
167
+
168
+ recognition.onstart = () => {
169
+ setIsRecording(true);
170
+ };
171
+
172
+ recognition.onresult = async (event: any) => {
173
+ setIsRecording(false);
174
+ const transcript = event.results[0][0].transcript;
175
+ if (transcript) {
176
+ setIsAiLoading(true);
177
+ // Extract DPR data locally from the voice transcript.
178
+ const extracted = await extractDPRData("Voice Note", data.boq, transcript, 'text/plain');
179
+ handleExtractionResult(extracted);
180
+ }
181
+ };
182
+
183
+ recognition.onerror = (event: any) => {
184
+ console.error("Speech recognition error", event.error);
185
+ setIsRecording(false);
186
+ if (event.error === 'not-allowed') {
187
+ alert("Please grant microphone permissions to use voice dictation.");
188
+ } else {
189
+ // Fallback simulation for preview environment without mic access
190
+ alert(`Microphone error: ${event.error}. Note: If inside an iframe without mic access, please open in a new tab.`);
191
+ }
192
+ };
193
+
194
+ recognition.start();
195
+ };
196
+
197
+ const handleExtractionResult = (extracted: any) => {
198
+ setIsAiLoading(false);
199
+ if (extracted) {
200
+ const newPopulated = new Set<string>();
201
+ if (extracted.date) { setActivityDate(extracted.date); newPopulated.add('date'); }
202
+ if (extracted.activity) { setActivityDesc(extracted.activity); newPopulated.add('activity'); }
203
+ if (extracted.location) { setLocation(extracted.location); newPopulated.add('location'); }
204
+ if (extracted.laborCount) { setLaborCount(extracted.laborCount); newPopulated.add('labor'); }
205
+ if (extracted.remarks) { setRemarks(extracted.remarks); newPopulated.add('remarks'); }
206
+ if (extracted.linkedBoqId) { setLinkedBoqId(extracted.linkedBoqId); newPopulated.add('boq'); }
207
+ if (extracted.workDoneQty) { setWorkDoneQty(extracted.workDoneQty); newPopulated.add('qty'); }
208
+
209
+ if (extracted.subContractorName) {
210
+ const match = data.subContractors?.find(s =>
211
+ s.name.toLowerCase().includes(extracted.subContractorName!.toLowerCase()) ||
212
+ extracted.subContractorName!.toLowerCase().includes(s.name.toLowerCase())
213
+ );
214
+ if (match) {
215
+ setSubContractorId(match.id);
216
+ newPopulated.add('subcontractor');
217
+ }
218
+ }
219
+
220
+ if (extracted.materials && extracted.materials.length > 0) {
221
+ const mappedMaterials = extracted.materials.map((m: any) => {
222
+ const match = data.materials.find(ex => ex.name.toLowerCase().includes(m.name.toLowerCase()));
223
+ return match ? { materialId: match.id, qty: m.qty } : null;
224
+ }).filter(Boolean) as MaterialConsumption[];
225
+
226
+ if (mappedMaterials.length > 0) {
227
+ setMaterialsConsumed(mappedMaterials);
228
+ newPopulated.add('materials');
229
+ }
230
+ }
231
+
232
+ setAiPopulatedFields(newPopulated);
233
+ }
234
+ };
235
+
236
+ const handleAiAutoFill = async () => {
237
+ const reportToAnalyze = selectedReportId
238
+ ? reportDocs.find(d => d.id === selectedReportId)
239
+ : reportDocs[0];
240
+
241
+ if (!reportToAnalyze) {
242
+ alert("Please upload or select a site report to analyze.");
243
+ return;
244
+ }
245
+
246
+ setIsAiLoading(true);
247
+ const extracted = await extractDPRData(reportToAnalyze.name, data.boq);
248
+ handleExtractionResult(extracted);
249
+ };
250
+
251
+ const addConsumptionRow = () => {
252
+ if (data.materials.length > 0) {
253
+ setMaterialsConsumed([...materialsConsumed, { materialId: data.materials[0].id, qty: 0 }]);
254
+ }
255
+ };
256
+
257
+ const updateConsumption = (index: number, field: keyof MaterialConsumption, value: any) => {
258
+ const updated = [...materialsConsumed];
259
+ updated[index] = { ...updated[index], [field]: value };
260
+ setMaterialsConsumed(updated);
261
+ };
262
+
263
+ const removeConsumptionRow = (index: number) => {
264
+ setMaterialsConsumed(materialsConsumed.filter((_, i) => i !== index));
265
+ };
266
+
267
+ const saveRemarks = (type: 'MATERIAL' | 'SUBCONTRACTOR', id: string) => {
268
+ onUpdatePDRemarks(type, id, tempRemarks);
269
+ setEditingRemarksId(null);
270
+ };
271
+
272
+ return (
273
+ <div className="space-y-8">
274
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
275
+ <div>
276
+ <h1 className="text-2xl font-bold text-slate-800">Site Execution</h1>
277
+ <p className="text-slate-500">Track Progress, Remaining Works & Daily Reports</p>
278
+ </div>
279
+ {canAddDPR ? (
280
+ <button
281
+ onClick={() => setIsDprModalOpen(true)}
282
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors shadow-sm text-sm font-medium"
283
+ >
284
+ <ClipboardCheck className="w-4 h-4" />
285
+ Add Daily Progress
286
+ </button>
287
+ ) : (
288
+ <div className="flex items-center gap-2 text-slate-400 bg-slate-100 px-3 py-1.5 rounded-lg text-sm">
289
+ <Lock className="w-3 h-3" />
290
+ <span>Read Only (Role: {userRole})</span>
291
+ </div>
292
+ )}
293
+ </div>
294
+
295
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
296
+ {/* Site Store Section */}
297
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-full flex flex-col">
298
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-indigo-50/50">
299
+ <div className="flex items-center gap-2">
300
+ <Package className="w-5 h-5 text-indigo-600" />
301
+ <h3 className="font-semibold text-slate-800">Site Store</h3>
302
+ </div>
303
+
304
+ <div className="flex items-center gap-3">
305
+ <div className="flex bg-slate-200/50 p-1 rounded-lg">
306
+ <button
307
+ onClick={() => setStoreView('INVENTORY')}
308
+ className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${storeView === 'INVENTORY' ? 'bg-white shadow text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
309
+ >
310
+ Stock
311
+ </button>
312
+ <button
313
+ onClick={() => setStoreView('HISTORY')}
314
+ className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${storeView === 'HISTORY' ? 'bg-white shadow text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
315
+ >
316
+ Log
317
+ </button>
318
+ </div>
319
+
320
+ {canManageStore && (
321
+ <button
322
+ onClick={() => setIsReceiveModalOpen(true)}
323
+ className="flex items-center gap-2 bg-white border border-indigo-200 text-indigo-600 px-3 py-1.5 rounded-lg hover:bg-indigo-50 transition-colors shadow-sm text-xs font-bold"
324
+ >
325
+ <ArrowDownLeft className="w-3.5 h-3.5" />
326
+ Inward
327
+ </button>
328
+ )}
329
+ </div>
330
+ </div>
331
+
332
+ {storeView === 'INVENTORY' ? (
333
+ <>
334
+ {/* Material Chart */}
335
+ {stockChartData.length > 0 && (
336
+ <div className="px-6 py-4 border-b border-slate-100 bg-slate-50/30">
337
+ <div className="h-48 w-full">
338
+ <ResponsiveContainer width="100%" height="100%">
339
+ <BarChart data={stockChartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
340
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
341
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} interval={0} />
342
+ <YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} />
343
+ <Tooltip
344
+ contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
345
+ cursor={{ fill: '#f1f5f9' }}
346
+ formatter={(value: number, name: string, props: any) => [`${value.toLocaleString()} ${props.payload.unit}`, name]}
347
+ labelFormatter={(label) => {
348
+ const item = stockChartData.find(d => d.name === label);
349
+ return item ? item.fullName : label;
350
+ }}
351
+ />
352
+ <Legend wrapperStyle={{ fontSize: '10px' }} />
353
+ <Bar dataKey="Received" fill="#6366f1" radius={[4, 4, 0, 0]} barSize={20} />
354
+ <Bar dataKey="Consumed" fill="#f59e0b" radius={[4, 4, 0, 0]} barSize={20} />
355
+ </BarChart>
356
+ </ResponsiveContainer>
357
+ </div>
358
+ </div>
359
+ )}
360
+
361
+ <div className="p-4 space-y-3 flex-1 overflow-y-auto max-h-[400px]">
362
+ {data.materials.map(mat => (
363
+ <div key={mat.id} className="border border-slate-200 rounded-lg p-3 hover:shadow-sm transition-shadow">
364
+ <div className="flex justify-between items-start mb-1">
365
+ <h4 className="font-bold text-slate-700 text-sm">{mat.name}</h4>
366
+ <span className="text-[10px] font-bold bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded">{mat.unit}</span>
367
+ </div>
368
+ <div className="grid grid-cols-3 gap-2 text-xs mb-2">
369
+ <div>
370
+ <span className="text-slate-400 block">Total Recv</span>
371
+ <span className="text-slate-600 font-medium">{mat.totalReceived.toLocaleString()}</span>
372
+ </div>
373
+ <div>
374
+ <span className="text-slate-400 block">Consumed</span>
375
+ <span className="text-slate-600 font-medium">{mat.totalConsumed.toLocaleString()}</span>
376
+ </div>
377
+ <div>
378
+ <span className="text-slate-400 block">Stock</span>
379
+ <span className="font-bold text-indigo-600">{mat.currentStock.toLocaleString()}</span>
380
+ </div>
381
+ </div>
382
+ <div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden">
383
+ <div className="h-full bg-indigo-500" style={{ width: `${Math.min(100, (mat.currentStock / (mat.totalReceived || 1)) * 100)}%` }}></div>
384
+ </div>
385
+ {/* Director Remarks */}
386
+ <div className="mt-2 pt-2 border-t border-slate-100">
387
+ {editingRemarksId === mat.id ? (
388
+ <div className="flex items-center gap-1">
389
+ <input
390
+ type="text"
391
+ value={tempRemarks}
392
+ onChange={(e) => setTempRemarks(e.target.value)}
393
+ className="w-full text-[10px] border border-indigo-300 rounded px-1 py-0.5 outline-none"
394
+ autoFocus
395
+ />
396
+ <button onClick={() => saveRemarks('MATERIAL', mat.id)} className="text-emerald-600"><Save className="w-3 h-3"/></button>
397
+ <button onClick={() => setEditingRemarksId(null)} className="text-red-500"><X className="w-3 h-3"/></button>
398
+ </div>
399
+ ) : (
400
+ <div className="flex items-start gap-1 group/remark">
401
+ <p className="text-[10px] text-slate-400 italic flex-1 truncate">
402
+ {mat.pdRemarks ? `Note: ${mat.pdRemarks}` : (isDirector ? "Add PD Note..." : "")}
403
+ </p>
404
+ {isDirector && (
405
+ <button
406
+ onClick={() => { setEditingRemarksId(mat.id); setTempRemarks(mat.pdRemarks || ''); }}
407
+ className="opacity-0 group-hover/remark:opacity-100 text-slate-400 hover:text-indigo-600 transition-opacity"
408
+ >
409
+ <Edit2 className="w-2.5 h-2.5" />
410
+ </button>
411
+ )}
412
+ </div>
413
+ )}
414
+ </div>
415
+ </div>
416
+ ))}
417
+ {data.materials.length === 0 && (
418
+ <div className="text-center py-8 text-slate-400 text-sm">No materials tracked.</div>
419
+ )}
420
+ </div>
421
+ </>
422
+ ) : (
423
+ <div className="flex-1 overflow-y-auto max-h-[600px] p-0">
424
+ <table className="w-full text-left text-xs">
425
+ <thead className="bg-slate-50 text-slate-500 font-semibold border-b border-slate-100 sticky top-0">
426
+ <tr>
427
+ <th className="px-4 py-2">Date</th>
428
+ <th className="px-4 py-2">Material</th>
429
+ <th className="px-4 py-2 text-right">Qty</th>
430
+ <th className="px-4 py-2">Activity</th>
431
+ </tr>
432
+ </thead>
433
+ <tbody className="divide-y divide-slate-50">
434
+ {consumptionHistory.map(row => (
435
+ <tr key={row.id} className="hover:bg-slate-50">
436
+ <td className="px-4 py-2 text-slate-500 whitespace-nowrap">{row.date}</td>
437
+ <td className="px-4 py-2 font-medium text-slate-700">{row.materialName}</td>
438
+ <td className="px-4 py-2 text-right font-mono text-indigo-600">{row.qty} {row.unit}</td>
439
+ <td className="px-4 py-2 text-slate-500 truncate max-w-[150px]" title={row.activity}>{row.activity}</td>
440
+ </tr>
441
+ ))}
442
+ {consumptionHistory.length === 0 && (
443
+ <tr>
444
+ <td colSpan={4} className="p-8 text-center text-slate-400">No consumption records found in DPRs.</td>
445
+ </tr>
446
+ )}
447
+ </tbody>
448
+ </table>
449
+ </div>
450
+ )}
451
+ </div>
452
+
453
+ {/* Engaged Sub-Contractors Section */}
454
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-full">
455
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-orange-50/50">
456
+ <div className="flex items-center gap-2">
457
+ <HardHat className="w-5 h-5 text-orange-600" />
458
+ <h3 className="font-semibold text-slate-800">Engaged Sub-Contractors</h3>
459
+ </div>
460
+ <span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-bold uppercase">Automated from Progress</span>
461
+ </div>
462
+ <div className="p-4 space-y-3">
463
+ {data.subContractors && data.subContractors.map(sc => (
464
+ <div key={sc.id} className="border border-slate-200 rounded-lg p-3 hover:shadow-sm transition-shadow">
465
+ <div className="flex justify-between items-start mb-1">
466
+ <div>
467
+ <h4 className="font-bold text-slate-700 text-sm">{sc.name}</h4>
468
+ <p className="text-[10px] text-slate-500">{sc.specialization}</p>
469
+ </div>
470
+ <span className="text-[10px] font-bold bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded border border-orange-100">
471
+ Due: ৳{sc.currentLiability.toLocaleString()}
472
+ </span>
473
+ </div>
474
+ <div className="grid grid-cols-2 gap-2 text-xs mt-2 mb-1">
475
+ <div className="bg-slate-50 p-1.5 rounded border border-slate-100">
476
+ <span className="text-slate-400 block text-[10px] uppercase">Work Done Value</span>
477
+ <span className="text-slate-700 font-bold">৳{sc.totalWorkValue.toLocaleString()}</span>
478
+ </div>
479
+ <div className="bg-slate-50 p-1.5 rounded border border-slate-100">
480
+ <span className="text-slate-400 block text-[10px] uppercase">Paid / Billed</span>
481
+ <span className="text-slate-700 font-bold">৳{sc.totalBilled.toLocaleString()}</span>
482
+ </div>
483
+ </div>
484
+ {/* Director Remarks */}
485
+ <div className="mt-1 pt-1 border-t border-slate-100">
486
+ {editingRemarksId === sc.id ? (
487
+ <div className="flex items-center gap-1">
488
+ <input
489
+ type="text"
490
+ value={tempRemarks}
491
+ onChange={(e) => setTempRemarks(e.target.value)}
492
+ className="w-full text-[10px] border border-orange-300 rounded px-1 py-0.5 outline-none"
493
+ autoFocus
494
+ />
495
+ <button onClick={() => saveRemarks('SUBCONTRACTOR', sc.id)} className="text-emerald-600"><Save className="w-3 h-3"/></button>
496
+ <button onClick={() => setEditingRemarksId(null)} className="text-red-500"><X className="w-3 h-3"/></button>
497
+ </div>
498
+ ) : (
499
+ <div className="flex items-start gap-1 group/remark">
500
+ <p className="text-[10px] text-slate-400 italic flex-1 truncate">
501
+ {sc.pdRemarks ? `Note: ${sc.pdRemarks}` : (isDirector ? "Add PD Note..." : "")}
502
+ </p>
503
+ {isDirector && (
504
+ <button
505
+ onClick={() => { setEditingRemarksId(sc.id); setTempRemarks(sc.pdRemarks || ''); }}
506
+ className="opacity-0 group-hover/remark:opacity-100 text-slate-400 hover:text-orange-600 transition-opacity"
507
+ >
508
+ <Edit2 className="w-2.5 h-2.5" />
509
+ </button>
510
+ )}
511
+ </div>
512
+ )}
513
+ </div>
514
+ </div>
515
+ ))}
516
+ {(!data.subContractors || data.subContractors.length === 0) && (
517
+ <div className="text-center py-8 text-slate-400 text-sm">No sub-contractors engaged.</div>
518
+ )}
519
+ </div>
520
+ </div>
521
+ </div>
522
+
523
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden mb-8">
524
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
525
+ <h3 className="font-semibold text-slate-800">Physical Progress & Remaining Works</h3>
526
+ <span className="text-xs font-medium text-slate-500 bg-white border border-slate-200 px-3 py-1 rounded-full">
527
+ Live from Site
528
+ </span>
529
+ </div>
530
+ <div className="overflow-x-auto">
531
+ <table className="w-full text-left text-sm">
532
+ <thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
533
+ <tr>
534
+ <th className="px-6 py-4 w-1/3">Item Description</th>
535
+ <th className="px-6 py-4 text-right">Planned Qty</th>
536
+ <th className="px-6 py-4 text-right">Executed Qty</th>
537
+ <th className="px-6 py-4 text-right font-semibold text-blue-700 bg-blue-50/50">Remaining Qty</th>
538
+ <th className="px-6 py-4 text-right">Progress %</th>
539
+ <th className="px-6 py-4 text-center">Status</th>
540
+ </tr>
541
+ </thead>
542
+ <tbody className="divide-y divide-slate-100">
543
+ {data.boq.map((item) => {
544
+ const percent = Math.min(100, Math.round((item.executedQty / item.plannedQty) * 100));
545
+ const remaining = Math.max(0, item.plannedQty - item.executedQty);
546
+ return (
547
+ <tr key={item.id} className="hover:bg-slate-50 transition-colors">
548
+ <td className="px-6 py-4 font-medium text-slate-700">
549
+ {item.description}
550
+ <div className="text-xs text-slate-400 font-normal mt-0.5">ID: {item.id}</div>
551
+ </td>
552
+ <td className="px-6 py-4 text-right text-slate-500">{item.plannedQty.toLocaleString()} <span className="text-xs">{item.unit}</span></td>
553
+ <td className="px-6 py-4 text-right text-slate-900 font-medium">{item.executedQty.toLocaleString()} <span className="text-xs">{item.unit}</span></td>
554
+ <td className="px-6 py-4 text-right font-bold text-blue-600 bg-blue-50/30">
555
+ {remaining.toLocaleString()} <span className="text-xs font-normal text-blue-400">{item.unit}</span>
556
+ </td>
557
+ <td className="px-6 py-4 text-right">
558
+ <div className="flex flex-col items-end gap-1">
559
+ <span className="text-xs font-bold text-slate-700">{percent}%</span>
560
+ <div className="w-24 bg-slate-200 h-1.5 rounded-full overflow-hidden">
561
+ <div className={`h-full rounded-full ${percent >= 100 ? 'bg-emerald-500' : 'bg-blue-600'}`} style={{ width: `${percent}%` }}></div>
562
+ </div>
563
+ </div>
564
+ </td>
565
+ <td className="px-6 py-4 text-center">
566
+ {percent >= 100 ? (
567
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 border border-emerald-200">
568
+ Completed
569
+ </span>
570
+ ) : percent > 0 ? (
571
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200">
572
+ In Progress
573
+ </span>
574
+ ) : (
575
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600 border border-slate-200">
576
+ Pending
577
+ </span>
578
+ )}
579
+ </td>
580
+ </tr>
581
+ );
582
+ })}
583
+ </tbody>
584
+ </table>
585
+ </div>
586
+ </div>
587
+
588
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
589
+ {/* DPRs */}
590
+ <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-fit">
591
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50">
592
+ <h3 className="font-semibold text-slate-800">Daily Progress Reports (DPR) Log</h3>
593
+ </div>
594
+ <div className="divide-y divide-slate-100 max-h-[400px] overflow-y-auto">
595
+ {data.dprs.map((dpr) => (
596
+ <div key={dpr.id} className="p-5 hover:bg-slate-50 transition-colors group">
597
+ <div className="flex justify-between items-start mb-2">
598
+ <div className="flex items-center gap-2">
599
+ <span className="w-2 h-2 rounded-full bg-blue-500"></span>
600
+ <h4 className="font-medium text-slate-900 text-sm group-hover:text-blue-600 transition-colors">{dpr.activity}</h4>
601
+ </div>
602
+ <span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">#{dpr.id}</span>
603
+ </div>
604
+
605
+ <div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
606
+ <div className="flex items-center gap-1.5 bg-slate-50 px-2 py-1 rounded">
607
+ <Calendar className="w-3.5 h-3.5 text-slate-400" />
608
+ <span>{dpr.date}</span>
609
+ </div>
610
+ <div className="flex items-center gap-1.5 bg-slate-50 px-2 py-1 rounded">
611
+ <MapPin className="w-3.5 h-3.5 text-slate-400" />
612
+ <span>{dpr.location}</span>
613
+ </div>
614
+ <div className="flex items-center gap-1.5 bg-slate-50 px-2 py-1 rounded">
615
+ <Users className="w-3.5 h-3.5 text-slate-400" />
616
+ <span>{dpr.laborCount} Workers</span>
617
+ </div>
618
+ </div>
619
+
620
+ {dpr.workDoneQty && dpr.linkedBoqId && (
621
+ <div className="mt-2 text-xs font-medium text-emerald-600 bg-emerald-50 w-fit px-2 py-0.5 rounded">
622
+ + {dpr.workDoneQty} Work Done Added
623
+ </div>
624
+ )}
625
+
626
+ {dpr.subContractorId && (
627
+ <div className="mt-1 text-[10px] font-bold text-orange-600 bg-orange-50 w-fit px-2 py-0.5 rounded border border-orange-100">
628
+ Engaged: {data.subContractors?.find(s => s.id === dpr.subContractorId)?.name}
629
+ </div>
630
+ )}
631
+
632
+ {dpr.materialsUsed && dpr.materialsUsed.length > 0 && (
633
+ <div className="mt-2 flex flex-wrap gap-2">
634
+ {dpr.materialsUsed.map((usage, idx) => {
635
+ const matName = data.materials.find(m => m.id === usage.materialId)?.name || usage.materialId;
636
+ return (
637
+ <span key={idx} className="text-[10px] bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded border border-indigo-100">
638
+ Consumed: {usage.qty} {data.materials.find(m => m.id === usage.materialId)?.unit} of {matName}
639
+ </span>
640
+ );
641
+ })}
642
+ </div>
643
+ )}
644
+
645
+ {dpr.remarks && (
646
+ <div className="mt-3 text-xs text-slate-600 italic border-l-2 border-slate-200 pl-3">
647
+ "{dpr.remarks}"
648
+ </div>
649
+ )}
650
+ </div>
651
+ ))}
652
+ {data.dprs.length === 0 && (
653
+ <div className="p-8 text-center text-slate-400 text-sm">No daily reports logged yet.</div>
654
+ )}
655
+ </div>
656
+ </div>
657
+
658
+ {/* Documents */}
659
+ <DocumentManager
660
+ documents={data.documents}
661
+ onAddDocument={onAddDocument}
662
+ filterModule="SITE"
663
+ compact={true}
664
+ allowUpload={canUploadDoc}
665
+ />
666
+ </div>
667
+
668
+ {/* Receive Material Modal */}
669
+ {isReceiveModalOpen && (
670
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
671
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-sm overflow-hidden">
672
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-indigo-50/50">
673
+ <h3 className="font-semibold text-slate-800">Receive Material (Inward)</h3>
674
+ <button onClick={() => setIsReceiveModalOpen(false)}><X className="w-5 h-5 text-slate-400" /></button>
675
+ </div>
676
+ <form onSubmit={handleReceiveSubmit} className="p-6 space-y-4">
677
+ <div>
678
+ <label className="block text-sm font-medium text-slate-700 mb-1">Select Material</label>
679
+ <select
680
+ required
681
+ value={receiveMatId}
682
+ onChange={(e) => setReceiveMatId(e.target.value)}
683
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm outline-none"
684
+ >
685
+ <option value="">-- Choose --</option>
686
+ {data.materials.map(m => <option key={m.id} value={m.id}>{m.name} ({m.unit})</option>)}
687
+ </select>
688
+ </div>
689
+ <div>
690
+ <label className="block text-sm font-medium text-slate-700 mb-1">Quantity Received</label>
691
+ <input
692
+ type="number"
693
+ required
694
+ min="0"
695
+ step="0.01"
696
+ value={receiveQty}
697
+ onChange={(e) => setReceiveQty(e.target.value)}
698
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm outline-none"
699
+ placeholder="e.g. 500"
700
+ />
701
+ </div>
702
+ <div>
703
+ <label className="block text-sm font-medium text-slate-700 mb-1">Buying Rate (Optional)</label>
704
+ <input
705
+ type="number"
706
+ min="0"
707
+ step="0.01"
708
+ value={receiveRate}
709
+ onChange={(e) => setReceiveRate(e.target.value)}
710
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm outline-none"
711
+ placeholder="Update Unit Price"
712
+ />
713
+ </div>
714
+ <button type="submit" className="w-full bg-indigo-600 text-white py-2 rounded-lg font-bold hover:bg-indigo-700">Add to Stock</button>
715
+ </form>
716
+ </div>
717
+ </div>
718
+ )}
719
+
720
+ {/* DPR Modal */}
721
+ {isDprModalOpen && (
722
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
723
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
724
+ <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50/30">
725
+ <h3 className="font-semibold text-slate-800">Add Daily Progress Report</h3>
726
+ <button onClick={() => setIsDprModalOpen(false)} className="text-slate-400 hover:text-slate-600">
727
+ <X className="w-5 h-5" />
728
+ </button>
729
+ </div>
730
+
731
+ <div className="p-4 bg-indigo-50/50 border-b border-indigo-100 flex flex-col gap-3">
732
+ <div className="flex items-center justify-between">
733
+ <div className="flex items-center gap-2 text-indigo-700 font-bold text-xs uppercase tracking-wider">
734
+ <Sparkles className="w-3.5 h-3.5" />
735
+ AI Smart Auto-Fill
736
+ </div>
737
+ {isAiLoading && (
738
+ <div className="flex items-center gap-1.5 text-indigo-600 text-[10px] font-bold animate-pulse">
739
+ <Loader2 className="w-3 h-3 animate-spin" /> Analyzing Report...
740
+ </div>
741
+ )}
742
+ </div>
743
+ <div className="flex items-center gap-2">
744
+ <div className="relative flex-1">
745
+ <FileText className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
746
+ <select
747
+ value={selectedReportId}
748
+ onChange={(e) => setSelectedReportId(e.target.value)}
749
+ className="w-full pl-9 pr-4 py-2 bg-white border border-indigo-200 rounded-lg text-xs outline-none focus:ring-2 focus:ring-indigo-400 transition-all"
750
+ >
751
+ <option value="">{reportDocs.length > 0 ? '-- Use Latest Report --' : 'No reports available'}</option>
752
+ {reportDocs.map(doc => (
753
+ <option key={doc.id} value={doc.id}>{doc.name} ({doc.uploadDate})</option>
754
+ ))}
755
+ </select>
756
+ </div>
757
+ <button
758
+ onClick={handleAiAutoFill}
759
+ disabled={isAiLoading || reportDocs.length === 0}
760
+ className="bg-indigo-600 text-white px-4 py-2 rounded-lg text-xs font-bold shadow-sm hover:bg-indigo-700 transition-all disabled:opacity-50 flex items-center gap-1.5"
761
+ >
762
+ Auto-Fill
763
+ </button>
764
+ <button
765
+ onClick={(e) => { e.preventDefault(); handleVoiceRecord(); }}
766
+ disabled={isRecording || isAiLoading}
767
+ className={`px-4 py-2 rounded-lg text-xs font-bold shadow-sm transition-all disabled:opacity-50 flex items-center gap-1.5 ${isRecording ? 'bg-red-500 text-white animate-pulse' : 'bg-white text-indigo-600 border border-indigo-200 hover:bg-indigo-50'}`}
768
+ title="Dictate Daily Progress"
769
+ >
770
+ <Mic className="w-3.5 h-3.5" />
771
+ {isRecording ? 'Listening...' : 'Dictate'}
772
+ </button>
773
+ </div>
774
+ {aiPopulatedFields.size > 0 && (
775
+ <div className="text-[10px] text-indigo-600 font-medium flex items-center gap-1.5">
776
+ <CheckCircle2 className="w-3 h-3" />
777
+ Successfully updated {aiPopulatedFields.size} fields
778
+ </div>
779
+ )}
780
+ </div>
781
+
782
+ <form onSubmit={handleCreateDPR} className="p-6 space-y-4 overflow-y-auto">
783
+ <div className="grid grid-cols-2 gap-4">
784
+ <div className="relative">
785
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
786
+ Date
787
+ {aiPopulatedFields.has('date') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
788
+ </label>
789
+ <input
790
+ type="date"
791
+ required
792
+ value={activityDate}
793
+ onChange={(e) => {
794
+ setActivityDate(e.target.value);
795
+ const next = new Set(aiPopulatedFields); next.delete('date'); setAiPopulatedFields(next);
796
+ }}
797
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all ${aiPopulatedFields.has('date') ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300'}`}
798
+ />
799
+ </div>
800
+ <div>
801
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
802
+ Labor Count
803
+ {aiPopulatedFields.has('labor') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
804
+ </label>
805
+ <input
806
+ type="number"
807
+ min="0"
808
+ value={laborCount}
809
+ onChange={(e) => {
810
+ setLaborCount(Number(e.target.value));
811
+ const next = new Set(aiPopulatedFields); next.delete('labor'); setAiPopulatedFields(next);
812
+ }}
813
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all ${aiPopulatedFields.has('labor') ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300'}`}
814
+ />
815
+ </div>
816
+ </div>
817
+
818
+ <div>
819
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
820
+ Link to BOQ Item (Activity Type)
821
+ {aiPopulatedFields.has('boq') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
822
+ </label>
823
+ <select
824
+ value={linkedBoqId}
825
+ onChange={(e) => {
826
+ setLinkedBoqId(e.target.value);
827
+ const next = new Set(aiPopulatedFields); next.delete('boq'); setAiPopulatedFields(next);
828
+ }}
829
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white transition-all ${aiPopulatedFields.has('boq') ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300'}`}
830
+ >
831
+ <option value="">-- General Activity (No BOQ Update) --</option>
832
+ {data.boq.map(item => (
833
+ <option key={item.id} value={item.id}>
834
+ {item.id} - {item.description.substring(0, 50)}...
835
+ </option>
836
+ ))}
837
+ </select>
838
+ </div>
839
+
840
+ {(linkedBoqId || aiPopulatedFields.has('qty')) && (
841
+ <div className={`p-4 rounded-lg border transition-all space-y-3 ${aiPopulatedFields.has('qty') ? 'bg-indigo-50 border-indigo-200' : 'bg-blue-50 border-blue-100'}`}>
842
+ <div>
843
+ <label className="block text-sm font-medium text-blue-800 mb-1 flex items-center gap-1">
844
+ Work Done Today (Quantity)
845
+ {aiPopulatedFields.has('qty') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
846
+ </label>
847
+ <div className="flex items-center gap-2">
848
+ <input
849
+ type="number"
850
+ min="0"
851
+ step="0.01"
852
+ required
853
+ value={workDoneQty}
854
+ onChange={(e) => {
855
+ setWorkDoneQty(Number(e.target.value));
856
+ const next = new Set(aiPopulatedFields); next.delete('qty'); setAiPopulatedFields(next);
857
+ }}
858
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all ${aiPopulatedFields.has('qty') ? 'border-indigo-300 bg-white' : 'border-blue-200 bg-white'}`}
859
+ />
860
+ <span className="text-sm font-medium text-blue-600">
861
+ {data.boq.find(b => b.id === linkedBoqId)?.unit}
862
+ </span>
863
+ </div>
864
+ </div>
865
+
866
+ <div>
867
+ <label className="block text-xs font-bold text-slate-500 uppercase mb-1 flex items-center gap-1">
868
+ Engaged Sub-Contractor
869
+ {aiPopulatedFields.has('subcontractor') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
870
+ </label>
871
+ <select
872
+ value={subContractorId}
873
+ onChange={(e) => {
874
+ setSubContractorId(e.target.value);
875
+ const next = new Set(aiPopulatedFields); next.delete('subcontractor'); setAiPopulatedFields(next);
876
+ }}
877
+ className={`w-full px-3 py-2 border rounded-lg text-sm outline-none transition-all ${aiPopulatedFields.has('subcontractor') ? 'border-indigo-300 bg-white' : 'border-slate-300 bg-white'}`}
878
+ >
879
+ <option value="">-- No Sub-Contractor (Direct Labor) --</option>
880
+ {data.subContractors?.map(sc => (
881
+ <option key={sc.id} value={sc.id}>
882
+ {sc.name} - (Rate: ৳{sc.agreedRates.find(r => r.boqId === linkedBoqId)?.rate || 0})
883
+ </option>
884
+ ))}
885
+ </select>
886
+ {subContractorId && linkedBoqId && (
887
+ <div className="text-[10px] text-orange-600 mt-1">
888
+ * Liability will be automatically created: ৳{(Number(workDoneQty) * (data.subContractors?.find(s => s.id === subContractorId)?.agreedRates.find(r => r.boqId === linkedBoqId)?.rate || 0)).toLocaleString()}
889
+ </div>
890
+ )}
891
+ </div>
892
+ </div>
893
+ )}
894
+
895
+ {/* Material Consumption Section */}
896
+ <div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
897
+ <div className="flex justify-between items-center mb-2">
898
+ <div className="flex items-center gap-2">
899
+ <label className="text-xs font-bold text-slate-600 uppercase">Materials Consumed Today</label>
900
+ {aiPopulatedFields.has('materials') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
901
+ </div>
902
+ <button type="button" onClick={addConsumptionRow} className="text-indigo-600 text-xs font-bold flex items-center gap-1">
903
+ <PlusCircle className="w-3 h-3" /> Add Material
904
+ </button>
905
+ </div>
906
+ <div className="space-y-2">
907
+ {materialsConsumed.map((row, idx) => {
908
+ const mat = data.materials.find(m => m.id === row.materialId);
909
+ const isError = mat ? row.qty > mat.currentStock : false;
910
+
911
+ return (
912
+ <div key={idx} className="flex gap-2 items-center">
913
+ <div className="flex-1 flex flex-col">
914
+ <select
915
+ value={row.materialId}
916
+ onChange={(e) => updateConsumption(idx, 'materialId', e.target.value)}
917
+ className={`text-xs border rounded px-2 py-1.5 ${aiPopulatedFields.has('materials') ? 'border-indigo-200 bg-indigo-50/20' : 'border-slate-300'}`}
918
+ >
919
+ {data.materials.map(m => <option key={m.id} value={m.id}>{m.name} (Stock: {m.currentStock})</option>)}
920
+ </select>
921
+ </div>
922
+ <div className="w-24 relative">
923
+ <input
924
+ type="number"
925
+ value={row.qty}
926
+ onChange={(e) => updateConsumption(idx, 'qty', Number(e.target.value))}
927
+ className={`w-full text-xs border rounded px-2 py-1.5 ${isError ? 'border-red-500 bg-red-50' : (aiPopulatedFields.has('materials') ? 'border-indigo-200 bg-indigo-50/20' : 'border-slate-300')}`}
928
+ placeholder="Qty"
929
+ />
930
+ {isError && <span className="absolute -bottom-3 left-0 text-[9px] text-red-500 font-bold flex items-center gap-0.5"><AlertCircle className="w-2 h-2"/> Over Limit</span>}
931
+ </div>
932
+ <span className="text-xs text-slate-500 w-8">{mat?.unit}</span>
933
+ <button type="button" onClick={() => removeConsumptionRow(idx)} className="text-red-500 hover:bg-red-50 p-1 rounded"><X className="w-3 h-3"/></button>
934
+ </div>
935
+ );
936
+ })}
937
+ {materialsConsumed.length === 0 && <p className="text-xs text-slate-400 italic">No materials added.</p>}
938
+ </div>
939
+ </div>
940
+
941
+ <div>
942
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
943
+ Activity Description
944
+ {aiPopulatedFields.has('activity') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
945
+ </label>
946
+ <input
947
+ type="text"
948
+ value={activityDesc}
949
+ onChange={(e) => {
950
+ setActivityDesc(e.target.value);
951
+ const next = new Set(aiPopulatedFields); next.delete('activity'); setAiPopulatedFields(next);
952
+ }}
953
+ placeholder={linkedBoqId ? "Auto-filled from BOQ if empty" : "e.g., Site Clearing, Mobilization"}
954
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all ${aiPopulatedFields.has('activity') ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300'}`}
955
+ />
956
+ </div>
957
+
958
+ <div>
959
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
960
+ Location
961
+ {aiPopulatedFields.has('location') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
962
+ </label>
963
+ <input
964
+ type="text"
965
+ value={location}
966
+ onChange={(e) => {
967
+ setLocation(e.target.value);
968
+ const next = new Set(aiPopulatedFields); next.delete('location'); setAiPopulatedFields(next);
969
+ }}
970
+ placeholder="e.g., Chainage 10+500"
971
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all ${aiPopulatedFields.has('location') ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300'}`}
972
+ />
973
+ </div>
974
+
975
+ <div>
976
+ <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1">
977
+ Remarks / Issues
978
+ {aiPopulatedFields.has('remarks') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />}
979
+ </label>
980
+ <textarea
981
+ rows={3}
982
+ value={remarks}
983
+ onChange={(e) => {
984
+ setRemarks(e.target.value);
985
+ const next = new Set(aiPopulatedFields); next.delete('remarks'); setAiPopulatedFields(next);
986
+ }}
987
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all ${aiPopulatedFields.has('remarks') ? 'border-indigo-300 bg-indigo-50/30' : 'border-slate-300'}`}
988
+ placeholder="Any notes..."
989
+ />
990
+ </div>
991
+
992
+ <div className="pt-2 flex justify-end gap-3 border-t border-slate-100 mt-2 pt-4">
993
+ <button
994
+ type="button"
995
+ onClick={() => { setIsDprModalOpen(false); resetForm(); }}
996
+ className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
997
+ >
998
+ Cancel
999
+ </button>
1000
+ <button
1001
+ type="submit"
1002
+ className="px-6 py-2 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shadow-md active:scale-95"
1003
+ >
1004
+ Save Progress
1005
+ </button>
1006
+ </div>
1007
+ </form>
1008
+ </div>
1009
+ </div>
1010
+ )}
1011
+ </div>
1012
+ );
1013
+ };
1014
+
1015
+ export default SiteExecution;
components/SubcontractorPortal.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { SubContractor, DPR } from '../types';
4
+ import {
5
+ Users,
6
+ Search,
7
+ Filter,
8
+ Plus,
9
+ MoreVertical,
10
+ DollarSign,
11
+ HardHat,
12
+ ChevronRight,
13
+ TrendingUp,
14
+ TrendingDown,
15
+ AlertCircle
16
+ } from 'lucide-react';
17
+
18
+ interface SubcontractorPortalProps {
19
+ subContractors: SubContractor[];
20
+ dprs: DPR[];
21
+ }
22
+
23
+ const SubcontractorPortal: React.FC<SubcontractorPortalProps> = ({ subContractors, dprs }) => {
24
+ const [searchQuery, setSearchQuery] = React.useState('');
25
+
26
+ const filteredSubContractors = subContractors.filter(s => s.name.toLowerCase().includes(searchQuery.toLowerCase()));
27
+
28
+ const getWorkDoneBySubcontractor = (subId: string) => {
29
+ return dprs.filter(d => d.subContractorId === subId);
30
+ };
31
+
32
+ return (
33
+ <div className="space-y-6">
34
+ {/* Header & Filters */}
35
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
36
+ <div className="flex items-center gap-4 flex-1 max-w-md">
37
+ <div className="relative flex-1">
38
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
39
+ <input
40
+ type="text"
41
+ placeholder="Search sub-contractors..."
42
+ value={searchQuery}
43
+ onChange={(e) => setSearchQuery(e.target.value)}
44
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
45
+ />
46
+ </div>
47
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
48
+ <Filter className="w-5 h-5" />
49
+ </button>
50
+ </div>
51
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
52
+ <Plus className="w-4 h-4" />
53
+ Add Sub-contractor
54
+ </button>
55
+ </div>
56
+
57
+ {/* Subcontractor Cards */}
58
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
59
+ {filteredSubContractors.map(sub => {
60
+ const workDone = getWorkDoneBySubcontractor(sub.id);
61
+ const liabilityPercent = (sub.currentLiability / sub.totalWorkValue) * 100;
62
+
63
+ return (
64
+ <div key={sub.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden hover:border-blue-300 transition-all group">
65
+ <div className="p-6 border-b border-slate-100 bg-slate-50/50">
66
+ <div className="flex items-start justify-between mb-4">
67
+ <div className="w-12 h-12 bg-white rounded-xl border border-slate-200 flex items-center justify-center shadow-sm group-hover:bg-blue-600 group-hover:text-white transition-all">
68
+ <Users className="w-6 h-6" />
69
+ </div>
70
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-white rounded-lg transition-all">
71
+ <MoreVertical className="w-4 h-4" />
72
+ </button>
73
+ </div>
74
+ <h3 className="font-bold text-slate-800 text-lg mb-1">{sub.name}</h3>
75
+ <span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-full border border-blue-100 uppercase tracking-wider">
76
+ {sub.specialization}
77
+ </span>
78
+ </div>
79
+
80
+ <div className="p-6 space-y-4">
81
+ <div className="grid grid-cols-2 gap-4">
82
+ <div className="p-3 bg-slate-50 rounded-xl">
83
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Work Value</span>
84
+ <p className="text-lg font-bold text-slate-800">৳{(sub.totalWorkValue / 100000).toFixed(1)}L</p>
85
+ </div>
86
+ <div className="p-3 bg-slate-50 rounded-xl">
87
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Liability</span>
88
+ <p className={`text-lg font-bold ${sub.currentLiability > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
89
+ ৳{(sub.currentLiability / 100000).toFixed(1)}L
90
+ </p>
91
+ </div>
92
+ </div>
93
+
94
+ <div className="space-y-2">
95
+ <div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase">
96
+ <span>Payment Progress</span>
97
+ <span>{((sub.totalBilled / sub.totalWorkValue) * 100).toFixed(0)}%</span>
98
+ </div>
99
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
100
+ <div
101
+ className="h-full bg-blue-500 transition-all"
102
+ style={{ width: `${(sub.totalBilled / sub.totalWorkValue) * 100}%` }}
103
+ />
104
+ </div>
105
+ </div>
106
+
107
+ <div className="flex items-center gap-3 text-xs text-slate-500 font-medium">
108
+ <div className="flex items-center gap-1">
109
+ <HardHat className="w-3 h-3" />
110
+ {workDone.length} DPRs
111
+ </div>
112
+ <div className="flex items-center gap-1">
113
+ <DollarSign className="w-3 h-3" />
114
+ {sub.agreedRates.length} Rates
115
+ </div>
116
+ </div>
117
+
118
+ {sub.currentLiability > (sub.totalWorkValue * 0.5) && (
119
+ <div className="flex items-center gap-2 text-[10px] font-bold text-amber-600 bg-amber-50 p-2 rounded-lg border border-amber-100">
120
+ <AlertCircle className="w-3 h-3" />
121
+ HIGH PENDING LIABILITY
122
+ </div>
123
+ )}
124
+
125
+ <button className="w-full py-2.5 bg-slate-900 text-white font-bold text-xs rounded-xl hover:bg-slate-800 transition-all flex items-center justify-center gap-2">
126
+ View Portal
127
+ <ChevronRight className="w-3 h-3" />
128
+ </button>
129
+ </div>
130
+ </div>
131
+ );
132
+ })}
133
+ </div>
134
+ </div>
135
+ );
136
+ };
137
+
138
+ export default SubcontractorPortal;
components/SustainabilityTracker.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { SustainabilityMetrics, Unit } from '../types';
4
+ import { Leaf, Recycle, Droplets, Wind, TrendingDown, AlertCircle, BarChart3 } from 'lucide-react';
5
+
6
+ interface SustainabilityTrackerProps {
7
+ metrics: SustainabilityMetrics;
8
+ }
9
+
10
+ const SustainabilityTracker: React.FC<SustainabilityTrackerProps> = ({ metrics }) => {
11
+ const totalWaste = metrics.wasteGenerated.reduce((acc, curr) => acc + curr.qty, 0);
12
+ const totalRecycled = metrics.wasteGenerated.reduce((acc, curr) => acc + curr.recycledQty, 0);
13
+ const recycleRate = (totalRecycled / (totalWaste || 1)) * 100;
14
+
15
+ return (
16
+ <div className="space-y-6">
17
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
18
+ {/* Carbon Footprint */}
19
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm relative overflow-hidden">
20
+ <div className="relative z-10">
21
+ <div className="flex items-center gap-3 mb-4">
22
+ <div className="w-10 h-10 bg-slate-100 text-slate-800 rounded-xl flex items-center justify-center">
23
+ <Wind className="w-5 h-5" />
24
+ </div>
25
+ <div>
26
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Carbon Footprint</p>
27
+ <p className="text-2xl font-black text-slate-800">{metrics.carbonFootprint.toLocaleString()} kg CO₂</p>
28
+ </div>
29
+ </div>
30
+ <div className="flex items-center gap-2 text-xs font-bold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-lg w-fit">
31
+ <TrendingDown className="w-3 h-3" />
32
+ <span>12% lower than benchmark</span>
33
+ </div>
34
+ </div>
35
+ <div className="absolute top-0 right-0 w-32 h-32 bg-slate-100/50 blur-[50px] rounded-full -mr-16 -mt-16"></div>
36
+ </div>
37
+
38
+ {/* Water Usage */}
39
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm relative overflow-hidden">
40
+ <div className="relative z-10">
41
+ <div className="flex items-center gap-3 mb-4">
42
+ <div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-xl flex items-center justify-center">
43
+ <Droplets className="w-5 h-5" />
44
+ </div>
45
+ <div>
46
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Water Usage</p>
47
+ <p className="text-2xl font-black text-slate-800">{metrics.waterUsage.toLocaleString()} L</p>
48
+ </div>
49
+ </div>
50
+ <p className="text-xs text-slate-500 font-medium">Site curing & dust suppression</p>
51
+ </div>
52
+ <div className="absolute top-0 right-0 w-32 h-32 bg-blue-50/50 blur-[50px] rounded-full -mr-16 -mt-16"></div>
53
+ </div>
54
+
55
+ {/* Waste Recycle Rate */}
56
+ <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm relative overflow-hidden">
57
+ <div className="relative z-10">
58
+ <div className="flex items-center gap-3 mb-4">
59
+ <div className="w-10 h-10 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center">
60
+ <Recycle className="w-5 h-5" />
61
+ </div>
62
+ <div>
63
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Recycle Rate</p>
64
+ <p className="text-2xl font-black text-slate-800">{recycleRate.toFixed(0)}%</p>
65
+ </div>
66
+ </div>
67
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
68
+ <div className="h-full bg-emerald-500" style={{ width: `${recycleRate}%` }} />
69
+ </div>
70
+ </div>
71
+ <div className="absolute top-0 right-0 w-32 h-32 bg-emerald-50/50 blur-[50px] rounded-full -mr-16 -mt-16"></div>
72
+ </div>
73
+ </div>
74
+
75
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
76
+ {/* Waste Breakdown */}
77
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
78
+ <div className="p-6 border-b border-slate-100 flex items-center justify-between">
79
+ <h3 className="font-bold text-slate-800 flex items-center gap-2">
80
+ <BarChart3 className="w-5 h-5 text-blue-600" />
81
+ Waste Breakdown
82
+ </h3>
83
+ </div>
84
+ <div className="p-6 space-y-6">
85
+ {metrics.wasteGenerated.map((waste, idx) => (
86
+ <div key={idx} className="space-y-2">
87
+ <div className="flex items-center justify-between text-sm">
88
+ <span className="font-bold text-slate-700">{waste.type}</span>
89
+ <span className="text-slate-500 font-medium">{waste.qty} {waste.unit}</span>
90
+ </div>
91
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden flex">
92
+ <div
93
+ className="h-full bg-emerald-500"
94
+ style={{ width: `${(waste.recycledQty / waste.qty) * 100}%` }}
95
+ title="Recycled"
96
+ />
97
+ <div
98
+ className="h-full bg-slate-300"
99
+ style={{ width: `${((waste.qty - waste.recycledQty) / waste.qty) * 100}%` }}
100
+ title="Landfill"
101
+ />
102
+ </div>
103
+ <div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-widest">
104
+ <span className="text-emerald-600">Recycled: {waste.recycledQty} {waste.unit}</span>
105
+ <span className="text-slate-400">Landfill: {waste.qty - waste.recycledQty} {waste.unit}</span>
106
+ </div>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ </div>
111
+
112
+ {/* Sustainability Tips */}
113
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
114
+ <h3 className="font-bold text-slate-800 mb-6 flex items-center gap-2">
115
+ <Leaf className="w-5 h-5 text-emerald-600" />
116
+ Green Site Recommendations
117
+ </h3>
118
+ <div className="space-y-4">
119
+ <div className="flex gap-4 p-4 bg-emerald-50 rounded-2xl border border-emerald-100">
120
+ <div className="w-8 h-8 bg-white rounded-xl flex items-center justify-center shrink-0 shadow-sm">
121
+ <AlertCircle className="w-4 h-4 text-emerald-600" />
122
+ </div>
123
+ <div>
124
+ <h4 className="text-sm font-bold text-emerald-900 mb-1">Optimize Concrete Curing</h4>
125
+ <p className="text-xs text-emerald-700 leading-relaxed">
126
+ Use curing compounds instead of continuous water spraying to reduce water consumption by up to 30%.
127
+ </p>
128
+ </div>
129
+ </div>
130
+ <div className="flex gap-4 p-4 bg-blue-50 rounded-2xl border border-blue-100">
131
+ <div className="w-8 h-8 bg-white rounded-xl flex items-center justify-center shrink-0 shadow-sm">
132
+ <AlertCircle className="w-4 h-4 text-blue-600" />
133
+ </div>
134
+ <div>
135
+ <h4 className="text-sm font-bold text-blue-900 mb-1">Steel Scrap Segregation</h4>
136
+ <p className="text-xs text-blue-700 leading-relaxed">
137
+ Implement a dedicated scrap yard for rebar off-cuts to ensure 100% recyclability and potential resale value.
138
+ </p>
139
+ </div>
140
+ </div>
141
+ <div className="flex gap-4 p-4 bg-amber-50 rounded-2xl border border-amber-100">
142
+ <div className="w-8 h-8 bg-white rounded-xl flex items-center justify-center shrink-0 shadow-sm">
143
+ <AlertCircle className="w-4 h-4 text-amber-600" />
144
+ </div>
145
+ <div>
146
+ <h4 className="text-sm font-bold text-amber-900 mb-1">Solar Lighting for Site Office</h4>
147
+ <p className="text-xs text-amber-700 leading-relaxed">
148
+ Switching to solar-powered site lighting can reduce operational carbon footprint by 500kg CO₂ per month.
149
+ </p>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ );
157
+ };
158
+
159
+ export default SustainabilityTracker;
components/TaskManager.tsx ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Task, User } from '../types';
3
+ import {
4
+ Plus,
5
+ Search,
6
+ Calendar,
7
+ User as UserIcon,
8
+ CheckCircle2,
9
+ Clock,
10
+ AlertCircle,
11
+ Trash2,
12
+ CheckCircle,
13
+ MessageSquare,
14
+ X
15
+ } from 'lucide-react';
16
+ import { CommentSection } from './Collaboration';
17
+ import { useLocalCollection } from '../hooks/useLocalCollection';
18
+
19
+ interface TaskManagerProps {
20
+ projectId: string;
21
+ currentUser: User;
22
+ }
23
+
24
+ const TaskManager: React.FC<TaskManagerProps> = ({ projectId, currentUser }) => {
25
+ const { data: tasks, add: addTask, update: updateTask, remove: removeTask } = useLocalCollection<Task & { id: string }>(`tasks_${projectId}`);
26
+ const [users, setUsers] = useState<User[]>([]);
27
+ const [isModalOpen, setIsModalOpen] = useState(false);
28
+ const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
29
+ const [searchQuery, setSearchQuery] = useState('');
30
+ const [statusFilter, setStatusFilter] = useState<string>('ALL');
31
+
32
+ const [newTask, setNewTask] = useState({
33
+ title: '',
34
+ description: '',
35
+ assignedTo: '',
36
+ dueDate: new Date().toISOString().split('T')[0],
37
+ priority: 'MEDIUM' as 'LOW' | 'MEDIUM' | 'HIGH'
38
+ });
39
+
40
+ useEffect(() => {
41
+ // In local mode, fetch from generic API
42
+ fetch('/api/collections/users')
43
+ .then(res => res.json())
44
+ .then(data => setUsers(data && data.length > 0 ? data : [currentUser]))
45
+ .catch(e => {
46
+ console.error(e);
47
+ setUsers([currentUser]);
48
+ });
49
+ }, [currentUser]);
50
+
51
+ const handleCreateTask = async (e: React.FormEvent) => {
52
+ e.preventDefault();
53
+ const taskId = `TASK-${Date.now()}`;
54
+ const taskData: Task & { id: string } = {
55
+ id: taskId,
56
+ ...newTask,
57
+ projectId,
58
+ status: 'PENDING',
59
+ createdAt: new Date().toISOString()
60
+ };
61
+
62
+ await addTask(taskData);
63
+
64
+ // Create a local notification via API
65
+ if (newTask.assignedTo) {
66
+ await fetch(`/api/collections/notifications_${newTask.assignedTo}`, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({
70
+ id: `NOTIF-${Date.now()}`,
71
+ recipientUid: newTask.assignedTo,
72
+ type: 'TASK_ASSIGNED',
73
+ title: 'New Task Assigned',
74
+ message: `You have been assigned a new task: ${newTask.title}`,
75
+ targetId: taskId,
76
+ isRead: false,
77
+ createdAt: new Date().toISOString()
78
+ })
79
+ });
80
+ }
81
+
82
+ setIsModalOpen(false);
83
+ setNewTask({
84
+ title: '',
85
+ description: '',
86
+ assignedTo: '',
87
+ dueDate: new Date().toISOString().split('T')[0],
88
+ priority: 'MEDIUM'
89
+ });
90
+ };
91
+
92
+ const handleUpdateStatus = async (taskId: string, newStatus: string) => {
93
+ updateTask(taskId, { status: newStatus as any });
94
+ };
95
+
96
+ const handleDeleteTask = async (taskId: string) => {
97
+ removeTask(taskId);
98
+ if (selectedTaskId === taskId) setSelectedTaskId(null);
99
+ };
100
+
101
+ const filteredTasks = tasks.filter(t => {
102
+ const matchesSearch = t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
103
+ t.description.toLowerCase().includes(searchQuery.toLowerCase());
104
+ const matchesStatus = statusFilter === 'ALL' || t.status === statusFilter;
105
+ return matchesSearch && matchesStatus;
106
+ });
107
+
108
+ const getPriorityColor = (priority: string) => {
109
+ switch(priority) {
110
+ case 'HIGH': return 'text-red-600 bg-red-50 border-red-100';
111
+ case 'MEDIUM': return 'text-amber-600 bg-amber-50 border-amber-100';
112
+ case 'LOW': return 'text-blue-600 bg-blue-50 border-blue-100';
113
+ default: return 'text-slate-600 bg-slate-50 border-slate-100';
114
+ }
115
+ };
116
+
117
+ const getStatusIcon = (status: string) => {
118
+ switch(status) {
119
+ case 'COMPLETED': return <CheckCircle2 className="w-4 h-4 text-emerald-500" />;
120
+ case 'IN_PROGRESS': return <Clock className="w-4 h-4 text-blue-500" />;
121
+ default: return <AlertCircle className="w-4 h-4 text-slate-400" />;
122
+ }
123
+ };
124
+
125
+ return (
126
+ <div className="flex h-full gap-6">
127
+ <div className="flex-1 flex flex-col gap-6">
128
+ {/* Header & Filters */}
129
+ <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
130
+ <div className="flex items-center gap-4 flex-1">
131
+ <div className="relative flex-1 max-w-md">
132
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
133
+ <input
134
+ type="text"
135
+ placeholder="Search tasks..."
136
+ value={searchQuery}
137
+ onChange={(e) => setSearchQuery(e.target.value)}
138
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
139
+ />
140
+ </div>
141
+ <select
142
+ value={statusFilter}
143
+ onChange={(e) => setStatusFilter(e.target.value)}
144
+ className="px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium text-slate-600 outline-none focus:ring-2 focus:ring-blue-500"
145
+ >
146
+ <option value="ALL">All Status</option>
147
+ <option value="PENDING">To Do</option>
148
+ <option value="IN_PROGRESS">In Progress</option>
149
+ <option value="COMPLETED">Completed</option>
150
+ </select>
151
+ </div>
152
+ <button
153
+ onClick={() => setIsModalOpen(true)}
154
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200"
155
+ >
156
+ <Plus className="w-4 h-4" />
157
+ New Task
158
+ </button>
159
+ </div>
160
+
161
+ {/* Task List */}
162
+ <div className="grid grid-cols-1 gap-4 overflow-y-auto pr-2">
163
+ {filteredTasks.length > 0 ? (
164
+ filteredTasks.map((task) => (
165
+ <div
166
+ key={task.id}
167
+ onClick={() => setSelectedTaskId(task.id)}
168
+ className={`bg-white p-4 rounded-xl border transition-all cursor-pointer group ${
169
+ selectedTaskId === task.id ? 'border-blue-500 shadow-md ring-1 ring-blue-500' : 'border-slate-200 hover:border-blue-300 hover:shadow-sm'
170
+ }`}
171
+ >
172
+ <div className="flex items-start justify-between mb-3">
173
+ <div className="flex items-center gap-3">
174
+ <button
175
+ onClick={(e) => {
176
+ e.stopPropagation();
177
+ handleUpdateStatus(task.id, task.status === 'COMPLETED' ? 'PENDING' : 'COMPLETED');
178
+ }}
179
+ className="transition-transform hover:scale-110"
180
+ >
181
+ {task.status === 'COMPLETED' ? (
182
+ <CheckCircle className="w-5 h-5 text-emerald-500" />
183
+ ) : (
184
+ <div className="w-5 h-5 rounded-full border-2 border-slate-300 group-hover:border-blue-400" />
185
+ )}
186
+ </button>
187
+ <div>
188
+ <h5 className={`font-bold text-slate-800 ${task.status === 'COMPLETED' ? 'line-through text-slate-400' : ''}`}>
189
+ {task.title}
190
+ </h5>
191
+ <div className="flex items-center gap-3 mt-1">
192
+ <span className={`text-[10px] font-bold px-2 py-0.5 rounded-full border ${getPriorityColor(task.priority)}`}>
193
+ {task.priority}
194
+ </span>
195
+ <div className="flex items-center gap-1 text-[10px] text-slate-500 font-medium">
196
+ <Calendar className="w-3 h-3" />
197
+ {new Date(task.dueDate).toLocaleDateString()}
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ <button
203
+ onClick={(e) => {
204
+ e.stopPropagation();
205
+ handleDeleteTask(task.id);
206
+ }}
207
+ className="p-1.5 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
208
+ >
209
+ <Trash2 className="w-4 h-4" />
210
+ </button>
211
+ </div>
212
+ <p className="text-xs text-slate-600 line-clamp-2 mb-4 pl-8">
213
+ {task.description}
214
+ </p>
215
+ <div className="flex items-center justify-between pl-8">
216
+ <div className="flex items-center gap-2">
217
+ {task.assignedTo ? (
218
+ <div className="flex items-center gap-1.5 bg-slate-100 px-2 py-1 rounded-lg">
219
+ <UserIcon className="w-3 h-3 text-slate-500" />
220
+ <span className="text-[10px] font-bold text-slate-700">
221
+ {users.find(u => u.uid === task.assignedTo)?.name || 'Assigned'}
222
+ </span>
223
+ </div>
224
+ ) : (
225
+ <span className="text-[10px] font-medium text-slate-400 italic">Unassigned</span>
226
+ )}
227
+ </div>
228
+ <div className="flex items-center gap-2">
229
+ <div className="flex items-center gap-1 text-[10px] font-bold text-slate-500">
230
+ <MessageSquare className="w-3 h-3" />
231
+ Comments
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ ))
237
+ ) : (
238
+ <div className="bg-white p-12 rounded-xl border border-dashed border-slate-300 text-center">
239
+ <CheckCircle2 className="w-12 h-12 text-slate-200 mx-auto mb-4" />
240
+ <p className="text-slate-500 font-medium">No tasks found matching your criteria.</p>
241
+ <button
242
+ onClick={() => setIsModalOpen(true)}
243
+ className="text-blue-600 text-sm font-bold mt-2 hover:underline"
244
+ >
245
+ Create your first task
246
+ </button>
247
+ </div>
248
+ )}
249
+ </div>
250
+ </div>
251
+
252
+ {/* Task Details / Comments Sidebar */}
253
+ {selectedTaskId && (
254
+ <div className="w-80 h-full flex flex-col animate-in slide-in-from-right duration-300">
255
+ <CommentSection
256
+ projectId={projectId}
257
+ targetId={selectedTaskId}
258
+ targetType="TASK"
259
+ currentUser={currentUser}
260
+ />
261
+ </div>
262
+ )}
263
+
264
+ {/* New Task Modal */}
265
+ {isModalOpen && (
266
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
267
+ <div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in zoom-in duration-200">
268
+ <div className="px-6 py-4 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
269
+ <h3 className="font-bold text-slate-800">Create New Task</h3>
270
+ <button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
271
+ <X className="w-5 h-5 text-slate-500" />
272
+ </button>
273
+ </div>
274
+ <form onSubmit={handleCreateTask} className="p-6 space-y-4">
275
+ <div>
276
+ <label className="block text-xs font-bold text-slate-500 uppercase mb-1.5">Task Title</label>
277
+ <input
278
+ required
279
+ type="text"
280
+ value={newTask.title}
281
+ onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
282
+ className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
283
+ placeholder="e.g. Complete foundation concrete"
284
+ />
285
+ </div>
286
+ <div>
287
+ <label className="block text-xs font-bold text-slate-500 uppercase mb-1.5">Description</label>
288
+ <textarea
289
+ value={newTask.description}
290
+ onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
291
+ className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all h-24 resize-none"
292
+ placeholder="Add more details about the task..."
293
+ />
294
+ </div>
295
+ <div className="grid grid-cols-2 gap-4">
296
+ <div>
297
+ <label className="block text-xs font-bold text-slate-500 uppercase mb-1.5">Assign To</label>
298
+ <select
299
+ value={newTask.assignedTo}
300
+ onChange={(e) => setNewTask({ ...newTask, assignedTo: e.target.value })}
301
+ className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
302
+ >
303
+ <option value="">Select Member</option>
304
+ {users.map(u => (
305
+ <option key={u.uid} value={u.uid}>{u.name} ({u.role})</option>
306
+ ))}
307
+ </select>
308
+ </div>
309
+ <div>
310
+ <label className="block text-xs font-bold text-slate-500 uppercase mb-1.5">Due Date</label>
311
+ <input
312
+ type="date"
313
+ value={newTask.dueDate}
314
+ onChange={(e) => setNewTask({ ...newTask, dueDate: e.target.value })}
315
+ className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
316
+ />
317
+ </div>
318
+ </div>
319
+ <div>
320
+ <label className="block text-xs font-bold text-slate-500 uppercase mb-1.5">Priority</label>
321
+ <div className="flex gap-2">
322
+ {(['LOW', 'MEDIUM', 'HIGH'] as const).map(p => (
323
+ <button
324
+ key={p}
325
+ type="button"
326
+ onClick={() => setNewTask({ ...newTask, priority: p })}
327
+ className={`flex-1 py-2 rounded-xl text-xs font-bold border transition-all ${
328
+ newTask.priority === p
329
+ ? getPriorityColor(p) + ' ring-2 ring-offset-1 ring-blue-500'
330
+ : 'bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100'
331
+ }`}
332
+ >
333
+ {p}
334
+ </button>
335
+ ))}
336
+ </div>
337
+ </div>
338
+ <div className="pt-4 flex gap-3">
339
+ <button
340
+ type="button"
341
+ onClick={() => setIsModalOpen(false)}
342
+ className="flex-1 px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all"
343
+ >
344
+ Cancel
345
+ </button>
346
+ <button
347
+ type="submit"
348
+ className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200"
349
+ >
350
+ Create Task
351
+ </button>
352
+ </div>
353
+ </form>
354
+ </div>
355
+ </div>
356
+ )}
357
+ </div>
358
+ );
359
+ };
360
+
361
+ export default TaskManager;
components/VendorAnalytics.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Vendor } from '../types';
4
+ import { ShoppingCart, Star, TrendingUp, TrendingDown, Package, Clock, ShieldCheck, Search, Filter, MoreVertical } from 'lucide-react';
5
+
6
+ interface VendorAnalyticsProps {
7
+ vendors: Vendor[];
8
+ }
9
+
10
+ const VendorAnalytics: React.FC<VendorAnalyticsProps> = ({ vendors }) => {
11
+ const [searchQuery, setSearchQuery] = React.useState('');
12
+
13
+ const filteredVendors = vendors.filter(v =>
14
+ v.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
15
+ v.category.toLowerCase().includes(searchQuery.toLowerCase())
16
+ );
17
+
18
+ const getRatingColor = (rating: number) => {
19
+ if (rating >= 4.5) return 'text-emerald-600 bg-emerald-50 border-emerald-100';
20
+ if (rating >= 3.5) return 'text-blue-600 bg-blue-50 border-blue-100';
21
+ if (rating >= 2.5) return 'text-amber-600 bg-amber-50 border-amber-100';
22
+ return 'text-red-600 bg-red-50 border-red-100';
23
+ };
24
+
25
+ return (
26
+ <div className="space-y-6">
27
+ <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-4">
28
+ <div className="flex items-center gap-4 flex-1 max-w-md">
29
+ <div className="relative flex-1">
30
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
31
+ <input
32
+ type="text"
33
+ placeholder="Search vendors..."
34
+ value={searchQuery}
35
+ onChange={(e) => setSearchQuery(e.target.value)}
36
+ className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none text-sm transition-all"
37
+ />
38
+ </div>
39
+ <button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
40
+ <Filter className="w-5 h-5" />
41
+ </button>
42
+ </div>
43
+ <button className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
44
+ <ShoppingCart className="w-4 h-4" />
45
+ New Purchase Order
46
+ </button>
47
+ </div>
48
+
49
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
50
+ {filteredVendors.length === 0 ? (
51
+ <div className="col-span-full py-20 text-center bg-white rounded-2xl border border-dashed border-slate-300">
52
+ <Package className="w-12 h-12 text-slate-300 mx-auto mb-4" />
53
+ <p className="text-slate-500 font-medium">No vendors found</p>
54
+ </div>
55
+ ) : (
56
+ filteredVendors.map(vendor => (
57
+ <div key={vendor.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group overflow-hidden">
58
+ <div className="p-6 border-b border-slate-100 bg-slate-50/50">
59
+ <div className="flex items-start justify-between mb-4">
60
+ <div className="w-12 h-12 bg-white rounded-xl border border-slate-200 flex items-center justify-center shadow-sm group-hover:bg-blue-600 group-hover:text-white transition-all">
61
+ <Package className="w-6 h-6" />
62
+ </div>
63
+ <div className={`flex items-center gap-1 px-2 py-1 rounded-full border text-[10px] font-bold ${getRatingColor(vendor.rating)}`}>
64
+ <Star className="w-3 h-3 fill-current" />
65
+ {vendor.rating.toFixed(1)}
66
+ </div>
67
+ </div>
68
+ <h3 className="font-bold text-slate-800 text-lg mb-1">{vendor.name}</h3>
69
+ <span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-full border border-blue-100 uppercase tracking-wider">
70
+ {vendor.category}
71
+ </span>
72
+ </div>
73
+
74
+ <div className="p-6 space-y-4">
75
+ <div className="grid grid-cols-2 gap-4">
76
+ <div className="p-3 bg-slate-50 rounded-xl">
77
+ <span className="text-[10px] font-bold text-slate-400 uppercase">Total Orders</span>
78
+ <p className="text-lg font-bold text-slate-800">{vendor.totalOrders}</p>
79
+ </div>
80
+ <div className="p-3 bg-slate-50 rounded-xl">
81
+ <span className="text-[10px] font-bold text-slate-400 uppercase">On-Time Rate</span>
82
+ <p className={`text-lg font-bold ${vendor.onTimeDeliveryRate > 90 ? 'text-emerald-600' : 'text-amber-600'}`}>
83
+ {vendor.onTimeDeliveryRate}%
84
+ </p>
85
+ </div>
86
+ </div>
87
+
88
+ <div className="space-y-2">
89
+ <div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase">
90
+ <span>Quality Score</span>
91
+ <span>{vendor.qualityScore}%</span>
92
+ </div>
93
+ <div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
94
+ <div
95
+ className="h-full bg-blue-500 transition-all"
96
+ style={{ width: `${vendor.qualityScore}%` }}
97
+ />
98
+ </div>
99
+ </div>
100
+
101
+ <div className="flex items-center justify-between pt-2">
102
+ <div className="flex items-center gap-2 text-xs text-slate-500 font-medium">
103
+ <Clock className="w-3 h-3" />
104
+ Avg. 4 days delivery
105
+ </div>
106
+ <button className="text-xs font-bold text-blue-600 hover:text-blue-700">View History</button>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ ))
111
+ )}
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default VendorAnalytics;
components/WeatherWidget.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { WeatherForecast } from '../types';
4
+ import { Cloud, Sun, CloudRain, Wind, Thermometer, AlertCircle, TrendingUp, TrendingDown } from 'lucide-react';
5
+
6
+ interface WeatherWidgetProps {
7
+ forecast: WeatherForecast[];
8
+ }
9
+
10
+ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ forecast }) => {
11
+ const today = forecast[0];
12
+
13
+ const getConditionIcon = (condition: string) => {
14
+ switch (condition.toLowerCase()) {
15
+ case 'sunny': return <Sun className="w-8 h-8 text-amber-500" />;
16
+ case 'rainy': return <CloudRain className="w-8 h-8 text-blue-500" />;
17
+ case 'cloudy': return <Cloud className="w-8 h-8 text-slate-400" />;
18
+ default: return <Sun className="w-8 h-8 text-amber-500" />;
19
+ }
20
+ };
21
+
22
+ const getImpactColor = (impact: string) => {
23
+ switch (impact) {
24
+ case 'STOP_WORK': return 'text-red-600 bg-red-50 border-red-100';
25
+ case 'CAUTION': return 'text-amber-600 bg-amber-50 border-amber-100';
26
+ case 'NONE': return 'text-emerald-600 bg-emerald-50 border-emerald-100';
27
+ default: return 'text-slate-600 bg-slate-50 border-slate-100';
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
33
+ <div className="p-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
34
+ <h3 className="font-bold text-slate-800 flex items-center gap-2">
35
+ <Sun className="w-5 h-5 text-amber-500" />
36
+ Site Weather Forecast
37
+ </h3>
38
+ <span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Dhaka, BD</span>
39
+ </div>
40
+
41
+ <div className="p-6">
42
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-8">
43
+ <div className="flex items-center gap-6">
44
+ <div className="w-20 h-20 bg-slate-50 rounded-3xl flex items-center justify-center shadow-inner">
45
+ {getConditionIcon(today?.condition || 'Sunny')}
46
+ </div>
47
+ <div>
48
+ <div className="flex items-baseline gap-1">
49
+ <span className="text-4xl font-black text-slate-800">{today?.temp || 32}°</span>
50
+ <span className="text-lg font-bold text-slate-400">C</span>
51
+ </div>
52
+ <p className="text-sm font-bold text-slate-600">{today?.condition || 'Sunny'}</p>
53
+ <div className="flex items-center gap-3 mt-1">
54
+ <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
55
+ <Thermometer className="w-3 h-3" />
56
+ <span>Feels like 35°</span>
57
+ </div>
58
+ <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
59
+ <Wind className="w-3 h-3" />
60
+ <span>12 km/h</span>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <div className={`p-4 rounded-2xl border flex items-start gap-3 max-w-xs ${getImpactColor(today?.impactOnSite || 'NONE')}`}>
67
+ <AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
68
+ <div>
69
+ <p className="text-[10px] font-bold uppercase tracking-widest mb-1">Site Impact Analysis</p>
70
+ <p className="text-xs font-bold leading-relaxed">
71
+ {today?.impactOnSite === 'STOP_WORK' ? 'Critical weather alert. All outdoor activities must be suspended.' :
72
+ today?.impactOnSite === 'CAUTION' ? 'Moderate rain expected. Secure materials and monitor drainage.' :
73
+ 'Ideal conditions for all site activities. No weather-related delays expected.'}
74
+ </p>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4">
80
+ {forecast.slice(1, 7).map((day, idx) => (
81
+ <div key={idx} className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col items-center text-center group hover:bg-white hover:border-blue-200 transition-all">
82
+ <span className="text-[10px] font-bold text-slate-400 uppercase mb-3">{day.date}</span>
83
+ <div className="mb-3 transform group-hover:scale-110 transition-transform">
84
+ {getConditionIcon(day.condition)}
85
+ </div>
86
+ <p className="text-sm font-bold text-slate-800">{day.temp}°</p>
87
+ <div className="flex items-center gap-1 mt-1">
88
+ <CloudRain className="w-2.5 h-2.5 text-blue-400" />
89
+ <span className="text-[10px] font-bold text-slate-400">{day.precipitationProbability}%</span>
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ </div>
96
+ );
97
+ };
98
+
99
+ export default WeatherWidget;
constants.ts ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { ProjectState, Unit } from './types';
3
+
4
+ const localPhoto = (title: string, color: string) =>
5
+ `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600"><rect width="800" height="600" fill="${color}"/><rect x="64" y="380" width="672" height="80" rx="12" fill="rgba(255,255,255,0.24)"/><text x="50%" y="52%" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#fff">${title}</text><text x="50%" y="68%" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#e5e7eb">Local project photo placeholder</text></svg>`)}`;
6
+
7
+ export const MOCK_PROJECTS: ProjectState[] = [
8
+ {
9
+ id: 'P001',
10
+ name: "Bank Protective Work at Munshirhat, Gaibandha (BWDB)",
11
+ ownerUid: 'system',
12
+ memberUids: ['system'],
13
+ status: 'ACTIVE',
14
+ priority: 'HIGH',
15
+ contractValue: 181592188,
16
+ startDate: "2023-09-25",
17
+ endDate: "2026-03-28",
18
+ materials: [
19
+ { id: 'MAT-01', name: 'Portland Composite Cement', unit: Unit.BAG, totalReceived: 5000, totalConsumed: 4200, currentStock: 800, averageRate: 540, pdRemarks: 'Ensure Holcim brand for casting' },
20
+ { id: 'MAT-02', name: 'Sylhet Sand (FM 2.5)', unit: Unit.CFT, totalReceived: 20000, totalConsumed: 15000, currentStock: 5000, averageRate: 45 },
21
+ { id: 'MAT-03', name: 'Stone Chips (3/4")', unit: Unit.CFT, totalReceived: 35000, totalConsumed: 28000, currentStock: 7000, averageRate: 185 },
22
+ { id: 'MAT-04', name: 'Geo-Textile Bags', unit: Unit.NOS, totalReceived: 50000, totalConsumed: 20404, currentStock: 29596, averageRate: 28 }
23
+ ],
24
+ subContractors: [
25
+ {
26
+ id: 'SC-01',
27
+ name: 'Sweet Chairman',
28
+ specialization: 'Earth Work & Geo-Bag',
29
+ totalWorkValue: 450000,
30
+ totalBilled: 155762,
31
+ currentLiability: 294238,
32
+ agreedRates: [
33
+ { boqId: '40-370-20', rate: 45.00 }, // Dumping Geo-bag labor rate
34
+ { boqId: '40-920', rate: 30.00 } // Earth work labor rate
35
+ ],
36
+ pdRemarks: 'Hold 10% retention from next bill.'
37
+ },
38
+ {
39
+ id: 'SC-02',
40
+ name: 'M/S Rahman Enterprise',
41
+ specialization: 'CC Block Casting',
42
+ totalWorkValue: 1200000,
43
+ totalBilled: 1000000,
44
+ currentLiability: 200000,
45
+ agreedRates: [
46
+ { boqId: '40-190-35', rate: 65.00 }, // Block casting labor rate
47
+ ]
48
+ }
49
+ ],
50
+ boq: [
51
+ {
52
+ id: '40-920',
53
+ description: 'Earth work in cutting and filling of eroded bank',
54
+ unit: Unit.CUM,
55
+ rate: 123.59,
56
+ plannedUnitCost: 105.00,
57
+ plannedBreakdown: { material: 50, labor: 30, equipment: 20, overhead: 5 },
58
+ plannedQty: 27977,
59
+ executedQty: 27977,
60
+ billedAmount: 3000000, // Partial billing
61
+ priority: 'MEDIUM',
62
+ costAnalysis: {
63
+ unitCost: 115.00,
64
+ breakdown: { material: 80, labor: 25, equipment: 10, overhead: 0 }
65
+ }
66
+ },
67
+ {
68
+ id: '40-370-20',
69
+ description: 'Supply, Filling and Dumping of Geo-bag',
70
+ unit: Unit.NOS,
71
+ rate: 295.00,
72
+ plannedUnitCost: 250.00,
73
+ plannedBreakdown: { material: 200, labor: 30, equipment: 15, overhead: 5 },
74
+ plannedQty: 20404,
75
+ executedQty: 20404,
76
+ billedAmount: 6019180, // Fully billed
77
+ priority: 'HIGH',
78
+ costAnalysis: {
79
+ unitCost: 280.00,
80
+ breakdown: { material: 220, labor: 40, equipment: 10, overhead: 10 }
81
+ }
82
+ },
83
+ {
84
+ id: '40-190-35',
85
+ description: 'CC blocks(1:2.5:5): 40cm x 40cm x 40cm',
86
+ unit: Unit.NOS,
87
+ rate: 852.00,
88
+ plannedUnitCost: 800.00,
89
+ plannedBreakdown: { material: 550, labor: 150, equipment: 70, overhead: 30 },
90
+ plannedQty: 47000,
91
+ executedQty: 18896,
92
+ billedAmount: 12000000,
93
+ priority: 'HIGH',
94
+ costAnalysis: {
95
+ unitCost: 910.00,
96
+ breakdown: { material: 600, labor: 200, equipment: 80, overhead: 30 }
97
+ }
98
+ },
99
+ {
100
+ id: '40-190-50',
101
+ description: 'CC blocks(1:2.5:5): 30cm x 30cm x 30cm',
102
+ unit: Unit.NOS,
103
+ rate: 362.00,
104
+ plannedUnitCost: 310.00,
105
+ plannedBreakdown: { material: 180, labor: 100, equipment: 20, overhead: 10 },
106
+ plannedQty: 70370,
107
+ executedQty: 32049,
108
+ billedAmount: 11000000,
109
+ priority: 'MEDIUM',
110
+ costAnalysis: {
111
+ unitCost: 330.00,
112
+ breakdown: { material: 200, labor: 100, equipment: 20, overhead: 10 }
113
+ }
114
+ },
115
+ {
116
+ id: '40-190-40',
117
+ description: 'CC blocks(1:2.5:5): 40cm x 40cm x 20cm',
118
+ unit: Unit.NOS,
119
+ rate: 432.00,
120
+ plannedUnitCost: 380.00,
121
+ plannedBreakdown: { material: 250, labor: 100, equipment: 20, overhead: 10 },
122
+ plannedQty: 118260,
123
+ executedQty: 15344,
124
+ billedAmount: 0,
125
+ priority: 'LOW',
126
+ costAnalysis: {
127
+ unitCost: 400.00,
128
+ breakdown: { material: 280, labor: 100, equipment: 10, overhead: 10 }
129
+ }
130
+ },
131
+ {
132
+ id: '40-290-10',
133
+ description: 'Dumping of stone/boulders/blocks by boat: Within 200m',
134
+ unit: Unit.CUM,
135
+ rate: 1638.00,
136
+ plannedUnitCost: 1450.00,
137
+ plannedBreakdown: { material: 1100, labor: 250, equipment: 100, overhead: 0 },
138
+ plannedQty: 3926.39,
139
+ executedQty: 981.60,
140
+ billedAmount: 0,
141
+ priority: 'MEDIUM',
142
+ costAnalysis: {
143
+ unitCost: 1400.00,
144
+ breakdown: { material: 1000, labor: 300, equipment: 100, overhead: 0 }
145
+ }
146
+ },
147
+ {
148
+ id: '40-500-40',
149
+ description: 'Supply and laying geotex filter',
150
+ unit: Unit.SQM,
151
+ rate: 202.00,
152
+ plannedUnitCost: 175.00,
153
+ plannedBreakdown: { material: 140, labor: 35, equipment: 0, overhead: 0 },
154
+ plannedQty: 24187.50,
155
+ executedQty: 12500,
156
+ billedAmount: 2000000,
157
+ priority: 'LOW',
158
+ costAnalysis: {
159
+ unitCost: 180.00,
160
+ breakdown: { material: 150, labor: 30, equipment: 0, overhead: 0 }
161
+ }
162
+ },
163
+ ],
164
+ dprs: [
165
+ { id: '105', date: '2024-11-19', activity: 'CC Block Manufacturing (Package-Munshirhat 01)', location: 'Casting Yard', laborCount: 30, remarks: 'Produced 97 nos 50x50x50 and 246 nos 40x40x40 blocks.', linkedBoqId: '40-190-35', subContractorId: 'SC-02', workDoneQty: 97, materialsUsed: [{ materialId: 'MAT-01', qty: 138 }, { materialId: 'MAT-02', qty: 250 }] },
166
+ { id: '106', date: '2024-11-19', activity: 'Geo-Bag Dumping by Boat', location: 'River Bank', laborCount: 19, remarks: 'Cumulative dumping progress 46.87%', linkedBoqId: '40-370-20', subContractorId: 'SC-01', workDoneQty: 150 },
167
+ { id: '107', date: '2024-12-30', activity: 'Monthly Reconciliation', location: 'Site Office', laborCount: 4, remarks: 'Gaibandha Munshirhat Block Casting Work Done Vol: 103385 cft' },
168
+ ],
169
+ bills: [
170
+ { id: 'RA-08', type: 'CLIENT_RA', entityName: 'BWDB Gaibandha O&M Division', amount: 12500000, date: '2024-10-15', status: 'PAID' },
171
+ { id: 'RA-09', type: 'CLIENT_RA', entityName: 'BWDB Gaibandha O&M Division', amount: 8599950, date: '2025-04-07', status: 'PENDING' },
172
+ { id: 'SUP-01', type: 'MATERIAL_EXPENSE', entityName: 'Hassan & Brothers Ltd (Supplier)', amount: 450000, date: '2024-11-20', status: 'PAID' },
173
+ { id: 'SUP-02', type: 'SUB_CONTRACTOR', entityName: 'Sweet Chairman (Sub-contractor)', amount: 155762, date: '2024-11-19', status: 'PENDING' },
174
+ ],
175
+ liabilities: [
176
+ { id: 'L001', description: 'Security Deposit (Retention 10%)', type: 'RETENTION', amount: 1250000, dueDate: '2026-03-28' },
177
+ { id: 'L002', description: 'Pending PO - Stone Chips (Sylhet)', type: 'PENDING_PO', amount: 867802, dueDate: '2024-12-01' },
178
+ { id: 'L003', description: 'Unbilled Labor (Nov)', type: 'UNBILLED_WORK', amount: 45000, dueDate: '2024-12-05' },
179
+ ],
180
+ milestones: [
181
+ { id: 'M1', title: 'Site Mobilization', date: '2023-10-01', status: 'COMPLETED', description: 'Site office setup and initial equipment deployment' },
182
+ { id: 'M2', title: 'Geo-Bag Dumping Completion', date: '2024-12-30', status: 'COMPLETED', description: 'Primary river bank protection layer' },
183
+ { id: 'M3', title: 'CC Block Casting (50%)', date: '2025-06-01', status: 'AT_RISK', description: 'Target 50% of total block volume cast' },
184
+ { id: 'M4', title: 'Pre-Monsoon Protection', date: '2025-05-15', status: 'PENDING', description: 'Critical protection works before water level rise' }
185
+ ],
186
+ documents: [
187
+ { id: 'D001', name: 'Running Bill RA-09.pdf', type: 'PDF', category: 'BILL', module: 'FINANCE', uploadDate: '2025-04-07', size: '1.4 MB' },
188
+ { id: 'D002', name: 'Daily Progress Report_19.11.25.pdf', type: 'PDF', category: 'REPORT', module: 'SITE', uploadDate: '2024-11-19', size: '2.1 MB' },
189
+ { id: 'D003', name: 'Profit_Loss_Summary_30.12.2024.xlsx', type: 'XLSX', category: 'REPORT', module: 'FINANCE', uploadDate: '2024-12-30', size: '0.5 MB' },
190
+ { id: 'D004', name: 'BOQ_Schedule.pdf', type: 'PDF', category: 'CONTRACT', module: 'MASTER', uploadDate: '2023-09-01', size: '3.8 MB' },
191
+ ],
192
+ aiSuggestions: [],
193
+ purchaseOrders: [
194
+ { id: 'PO-001', materialId: 'MAT-01', vendorName: 'Holcim Cement Ltd.', qty: 2000, rate: 540, totalAmount: 1080000, status: 'DELIVERED', orderDate: '2025-03-10', actualDeliveryDate: '2025-03-15' },
195
+ { id: 'PO-002', materialId: 'MAT-04', vendorName: 'Geo-Tech Solutions', qty: 10000, rate: 28, totalAmount: 280000, status: 'SENT', orderDate: '2025-04-01', expectedDeliveryDate: '2025-04-10' }
196
+ ],
197
+ qualityChecks: [
198
+ { id: 'QC-01', title: 'Concrete Slump Test', location: 'Block A Casting', inspectorUid: 'system', date: '2025-04-05', status: 'PASSED', items: [{ description: 'Slump value within 75-100mm', isOk: true }, { description: 'Correct water-cement ratio', isOk: true }] },
199
+ { id: 'QC-02', title: 'Rebar Inspection', location: 'Section 4 Foundation', inspectorUid: 'system', date: '2025-04-06', status: 'PENDING', items: [{ description: 'Correct spacing as per drawing', isOk: true }, { description: 'Rust-free rebar', isOk: false, remarks: 'Minor rust on top layer' }] }
200
+ ],
201
+ safetyChecks: [
202
+ { id: 'S-01', date: '2025-04-01', inspectorUid: 'system', score: 92, hazardsIdentified: ['Loose scaffolding'], correctiveActions: ['Tightened all joints'], status: 'SAFE' },
203
+ { id: 'S-02', date: '2025-04-06', inspectorUid: 'system', score: 65, hazardsIdentified: ['Missing PPE', 'Open excavation'], correctiveActions: ['Issued warnings', 'Fencing required'], status: 'ACTION_REQUIRED' }
204
+ ],
205
+ photoLogs: [
206
+ { id: 'PH-01', url: localPhoto('Foundation Pouring', '#1d4ed8'), caption: 'Foundation pouring at Section A', location: 'Section A', uploadedBy: 'system', createdAt: '2025-04-01T10:00:00Z', tags: ['foundation', 'concrete'] },
207
+ { id: 'PH-02', url: localPhoto('Material Delivery', '#047857'), caption: 'Material delivery - Cement', location: 'Main Store', uploadedBy: 'system', createdAt: '2025-04-05T14:30:00Z', tags: ['delivery', 'cement'] }
208
+ ],
209
+ equipment: [
210
+ { id: 'EQ-01', name: 'Excavator CAT 320', type: 'Heavy Machinery', status: 'OPERATIONAL', lastMaintenance: '2025-03-01', nextMaintenance: '2025-06-01', assignedOperator: 'Rahim Uddin', hourlyRate: 2500 },
211
+ { id: 'EQ-02', name: 'Concrete Mixer 10/7', type: 'Mixing Equipment', status: 'MAINTENANCE', lastMaintenance: '2025-04-01', nextMaintenance: '2025-04-10', assignedOperator: 'Karim Mia', hourlyRate: 800 }
212
+ ],
213
+ attendance: [
214
+ { id: 'ATT-01', date: '2025-04-07', workerName: 'Abul Kashem', category: 'SKILLED', checkIn: '08:00', status: 'PRESENT' },
215
+ { id: 'ATT-02', date: '2025-04-07', workerName: 'Sujon Ahmed', category: 'UNSKILLED', checkIn: '08:15', status: 'PRESENT' },
216
+ { id: 'ATT-03', date: '2025-04-07', workerName: 'Mofizul Islam', category: 'UNSKILLED', checkIn: '', status: 'ABSENT' }
217
+ ],
218
+ changeOrders: [
219
+ { id: 'CO-01', title: 'Additional Retaining Wall', description: 'Request for extra 50m retaining wall due to soil instability at Section B.', requestedBy: 'Site Engineer', status: 'PENDING', estimatedCost: 1500000, date: '2025-04-05' }
220
+ ],
221
+ vendors: [
222
+ { id: 'VEN-01', name: 'Holcim Cement Ltd.', category: 'Cement', rating: 4.8, totalOrders: 15, onTimeDeliveryRate: 95, qualityScore: 98 },
223
+ { id: 'VEN-02', name: 'BSRM Steels', category: 'Rebar', rating: 4.5, totalOrders: 8, onTimeDeliveryRate: 90, qualityScore: 96 }
224
+ ],
225
+ riskAssessment: {
226
+ lastUpdated: '2025-04-07',
227
+ overallRiskScore: 42,
228
+ risks: [
229
+ { category: 'Weather', description: 'Upcoming monsoon season may delay river bank protection.', impact: 'HIGH', probability: 0.8, mitigation: 'Accelerate geo-bag dumping and complete primary layer before May 15th.' },
230
+ { category: 'Supply Chain', description: 'Potential shortage of Sylhet sand due to transport strike.', impact: 'MEDIUM', probability: 0.3, mitigation: 'Pre-order and stockpile 5000 CFT additional sand.' }
231
+ ]
232
+ },
233
+ weatherForecast: [
234
+ { date: '2025-04-07', temp: 32, condition: 'Sunny', precipitationProbability: 10, impactOnSite: 'NONE' },
235
+ { date: '2025-04-08', temp: 31, condition: 'Cloudy', precipitationProbability: 20, impactOnSite: 'NONE' },
236
+ { date: '2025-04-09', temp: 28, condition: 'Rainy', precipitationProbability: 80, impactOnSite: 'CAUTION' }
237
+ ],
238
+ sustainabilityMetrics: {
239
+ carbonFootprint: 125400,
240
+ waterUsage: 450000,
241
+ wasteGenerated: [
242
+ { type: 'Concrete Waste', qty: 50, unit: Unit.CUM, recycledQty: 35 },
243
+ { type: 'Steel Scrap', qty: 5, unit: Unit.TON, recycledQty: 5 }
244
+ ]
245
+ },
246
+ bimModels: [
247
+ { id: 'BIM-01', name: 'Main Structure - Architectural', url: '#', version: '2.1', uploadedAt: '2025-03-15' },
248
+ { id: 'BIM-02', name: 'MEP Layout - Section A', url: '#', version: '1.0', uploadedAt: '2025-04-01' }
249
+ ]
250
+ },
251
+ {
252
+ id: 'P002',
253
+ name: "New Construction Project",
254
+ ownerUid: 'system',
255
+ memberUids: ['system'],
256
+ status: 'ACTIVE',
257
+ priority: 'MEDIUM',
258
+ contractValue: 0,
259
+ startDate: "2025-01-01",
260
+ endDate: "2025-12-31",
261
+ materials: [],
262
+ subContractors: [],
263
+ boq: [],
264
+ dprs: [],
265
+ bills: [],
266
+ liabilities: [],
267
+ milestones: [],
268
+ documents: [],
269
+ aiSuggestions: []
270
+ }
271
+ ];
contexts/NotificationContext.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useCallback } from 'react';
2
+
3
+ type ToastType = 'success' | 'error' | 'info';
4
+
5
+ interface Toast {
6
+ id: string;
7
+ message: string;
8
+ type: ToastType;
9
+ }
10
+
11
+ interface NotificationContextType {
12
+ showToast: (message: string, type: ToastType) => void;
13
+ }
14
+
15
+ const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
16
+
17
+ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
18
+ const [toasts, setToasts] = useState<Toast[]>([]);
19
+
20
+ const showToast = useCallback((message: string, type: ToastType) => {
21
+ const id = Math.random().toString(36).substring(7);
22
+ setToasts((prev) => [...prev, { id, message, type }]);
23
+ setTimeout(() => {
24
+ setToasts((prev) => prev.filter((t) => t.id !== id));
25
+ }, 5000);
26
+ }, []);
27
+
28
+ return (
29
+ <NotificationContext.Provider value={{ showToast }}>
30
+ {children}
31
+ <div className="fixed top-4 right-4 z-50 space-y-2">
32
+ {toasts.map((toast) => (
33
+ <div key={toast.id} className={`p-4 rounded-lg shadow-lg text-white font-medium ${toast.type === 'success' ? 'bg-emerald-600' : toast.type === 'error' ? 'bg-red-600' : 'bg-blue-600'}`}>
34
+ {toast.message}
35
+ </div>
36
+ ))}
37
+ </div>
38
+ </NotificationContext.Provider>
39
+ );
40
+ };
41
+
42
+ export const useNotification = () => {
43
+ const context = useContext(NotificationContext);
44
+ if (!context) throw new Error('useNotification must be used within NotificationProvider');
45
+ return context;
46
+ };
hooks/useLocalCollection.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * Helper to dynamically grab auth token if available.
5
+ */
6
+ const getAuthHeaders = () => {
7
+ const token = localStorage.getItem('auth_token');
8
+ return {
9
+ 'Content-Type': 'application/json',
10
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {})
11
+ };
12
+ };
13
+
14
+ /**
15
+ * A hook to manage a generic collection via our Express MongoDB API.
16
+ * Uses optimistic updates for a snappy UX without websockets.
17
+ */
18
+ export function useLocalCollection<T extends { id: string }>(collectionName: string, refreshKey = '') {
19
+ const [data, setData] = useState<T[]>([]);
20
+
21
+ useEffect(() => {
22
+ let mounted = true;
23
+ const token = localStorage.getItem('auth_token');
24
+ fetch(`/api/collections/${collectionName}`, {
25
+ headers: token ? { 'Authorization': `Bearer ${token}` } : {}
26
+ })
27
+ .then(res => {
28
+ if (res.status === 401 || res.status === 403) return []; // Not logged in
29
+ if (!res.ok) throw new Error("Fetch failed");
30
+ return res.json();
31
+ })
32
+ .then(result => {
33
+ if (mounted) {
34
+ setData(Array.isArray(result) ? result : []);
35
+ if (!Array.isArray(result)) {
36
+ console.error(`Expected array for ${collectionName}, got:`, result);
37
+ }
38
+ }
39
+ })
40
+ .catch(err => console.error(`Error fetching ${collectionName}:`, err));
41
+
42
+ return () => { mounted = false; };
43
+ }, [collectionName, refreshKey]);
44
+
45
+ const add = async (item: T) => {
46
+ setData(prev => [...prev, item]); // Optimistic
47
+ try {
48
+ await fetch(`/api/collections/${collectionName}`, {
49
+ method: 'POST',
50
+ headers: getAuthHeaders(),
51
+ body: JSON.stringify(item)
52
+ });
53
+ } catch (e) {
54
+ console.error(`Error adding to ${collectionName}:`, e);
55
+ }
56
+ };
57
+
58
+ const update = async (id: string, updates: Partial<T>) => {
59
+ setData(prev => prev.map(d => d.id === id ? { ...d, ...updates } : d)); // Optimistic
60
+ try {
61
+ await fetch(`/api/collections/${collectionName}/${id}`, {
62
+ method: 'PUT',
63
+ headers: getAuthHeaders(),
64
+ body: JSON.stringify(updates)
65
+ });
66
+ } catch (e) {
67
+ console.error(`Error updating ${collectionName}:`, e);
68
+ }
69
+ };
70
+
71
+ const remove = async (id: string) => {
72
+ setData(prev => prev.filter(d => d.id !== id)); // Optimistic
73
+ try {
74
+ const token = localStorage.getItem('auth_token');
75
+ await fetch(`/api/collections/${collectionName}/${id}`, {
76
+ method: 'DELETE',
77
+ headers: token ? { 'Authorization': `Bearer ${token}` } : {}
78
+ });
79
+ } catch (e) {
80
+ console.error(`Error deleting from ${collectionName}:`, e);
81
+ }
82
+ };
83
+
84
+ return { data, add, update, remove };
85
+ }
index.css ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
2
+ @import "tailwindcss";
3
+
4
+ @theme {
5
+ --color-background: #f8fafc;
6
+ --color-foreground: #0f172a;
7
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
8
+ --font-heading: "Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif;
9
+
10
+ --radius-xl: 0.5rem;
11
+ --radius-2xl: 0.75rem;
12
+ --radius-3xl: 1rem;
13
+ }
14
+
15
+ @layer base {
16
+ body {
17
+ @apply bg-background text-foreground antialiased;
18
+ }
19
+
20
+ h1, h2, h3, h4, h5, h6 {
21
+ @apply font-heading font-bold tracking-tight text-slate-900;
22
+ }
23
+ }
24
+
25
+ @layer components {
26
+ .card {
27
+ @apply bg-white border border-slate-200/80 rounded-xl shadow-[0_1px_3px_0_rgba(0,0,0,0.05)] transition-all duration-300;
28
+ }
29
+
30
+ .card-hover {
31
+ @apply hover:shadow-md hover:border-slate-300;
32
+ }
33
+
34
+ .btn-primary {
35
+ @apply bg-slate-900 text-white px-4 py-2 rounded-md font-semibold text-sm transition-all active:scale-95 hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm;
36
+ }
37
+
38
+ .btn-secondary {
39
+ @apply bg-white text-slate-700 border border-slate-200 px-4 py-2 rounded-md font-semibold text-sm transition-all active:scale-95 hover:bg-slate-50 shadow-sm;
40
+ }
41
+
42
+ .sidebar-item {
43
+ @apply flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 font-medium text-sm;
44
+ }
45
+
46
+ .sidebar-item-active {
47
+ @apply bg-slate-900 text-white shadow-sm;
48
+ }
49
+
50
+ .sidebar-item-inactive {
51
+ @apply text-slate-500 hover:bg-slate-100/50 hover:text-slate-900;
52
+ }
53
+ }
54
+
55
+ /* Custom scrollbar for desktop */
56
+ @media (min-width: 1024px) {
57
+ ::-webkit-scrollbar {
58
+ width: 6px;
59
+ height: 6px;
60
+ }
61
+
62
+ ::-webkit-scrollbar-track {
63
+ @apply bg-transparent;
64
+ }
65
+
66
+ ::-webkit-scrollbar-thumb {
67
+ @apply bg-slate-200 rounded-full hover:bg-slate-300;
68
+ }
69
+ }
70
+
71
+ .markdown-body {
72
+ @apply leading-relaxed;
73
+ }
74
+
75
+ .markdown-body h1, .markdown-body h2, .markdown-body h3 {
76
+ @apply mt-6 mb-4 font-heading font-bold text-slate-900;
77
+ }
78
+
79
+ .markdown-body p {
80
+ @apply mb-4 text-slate-600;
81
+ }
82
+
83
+ .markdown-body ul {
84
+ @apply list-disc pl-5 mb-4 space-y-2;
85
+ }
86
+
87
+ .markdown-body li {
88
+ @apply text-slate-600;
89
+ }
index.html ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Construction project management - AI</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ body {
11
+ font-family: 'Inter', sans-serif;
12
+ background-color: #f8fafc;
13
+ }
14
+ </style>
15
+ <script type="importmap">
16
+ {
17
+ "imports": {
18
+ "lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
19
+ "@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
20
+ "react": "https://aistudiocdn.com/react@^19.2.0",
21
+ "react/": "https://aistudiocdn.com/react@^19.2.0/",
22
+ "react-markdown": "https://aistudiocdn.com/react-markdown@^10.1.0",
23
+ "recharts": "https://aistudiocdn.com/recharts@^3.5.0",
24
+ "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
25
+ }
26
+ }
27
+ </script>
28
+ <link rel="stylesheet" href="/index.css">
29
+ </head>
30
+ <body>
31
+ <div id="root"></div>
32
+ <script type="module" src="/index.tsx"></script>
33
+ </body>
34
+ </html>
index.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ const rootElement = document.getElementById('root');
6
+ if (!rootElement) {
7
+ throw new Error("Could not find root element to mount to");
8
+ }
9
+
10
+ const root = ReactDOM.createRoot(rootElement);
11
+ root.render(
12
+ <React.StrictMode>
13
+ <App />
14
+ </React.StrictMode>
15
+ );
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "Construction project management - AI",
3
+ "description": "A comprehensive construction project management system tracking master controls, site execution, financials, and liabilities with AI-powered insights.",
4
+ "requestFramePermissions": []
5
+ }
migrated_prompt_history/prompt_2025-11-26T21_18_26.606Z.json ADDED
The diff for this file is too large to render. See raw diff
 
migrated_prompt_history/prompt_2025-11-26T22_34_44.634Z.json ADDED
The diff for this file is too large to render. See raw diff
 
migrated_prompt_history/prompt_2026-01-21T18_05_04.673Z.json ADDED
The diff for this file is too large to render. See raw diff
 
migrated_prompt_history/prompt_2026-01-21T23_54_40.133Z.json ADDED
The diff for this file is too large to render. See raw diff
 
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "buildtrack-local---construction-management-system",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx server.ts",
8
+ "build": "vite build",
9
+ "start": "tsx server.ts",
10
+ "preview": "vite preview",
11
+ "lint": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "bcryptjs": "^3.0.3",
15
+ "clsx": "^2.1.1",
16
+ "date-fns": "^4.1.0",
17
+ "express": "^5.2.1",
18
+ "express-session": "^1.19.0",
19
+ "framer-motion": "^12.38.0",
20
+ "jsonwebtoken": "^9.0.3",
21
+ "jspdf": "^4.2.1",
22
+ "jspdf-autotable": "^5.0.7",
23
+ "lucide-react": "^0.555.0",
24
+ "mongoose": "^9.4.1",
25
+ "passport": "^0.7.0",
26
+ "react": "^19.2.0",
27
+ "react-dom": "^19.2.0",
28
+ "react-markdown": "^10.1.0",
29
+ "recharts": "^3.5.0",
30
+ "tailwind-merge": "^3.5.0",
31
+ "tsx": "^4.21.0"
32
+ },
33
+ "devDependencies": {
34
+ "@tailwindcss/vite": "^4.2.2",
35
+ "@types/bcryptjs": "^2.4.6",
36
+ "@types/express": "^5.0.6",
37
+ "@types/node": "^22.14.0",
38
+ "@vitejs/plugin-react": "^5.0.0",
39
+ "tailwindcss": "^4.2.2",
40
+ "typescript": "~5.8.2",
41
+ "vite": "^6.2.0"
42
+ }
43
+ }
server.ts ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import mongoose from 'mongoose';
3
+ import path from 'path';
4
+ import jwt from 'jsonwebtoken';
5
+ import bcrypt from 'bcryptjs';
6
+
7
+ async function startServer() {
8
+ const app = express();
9
+ const PORT = Number(process.env.PORT || 7860);
10
+
11
+ app.use(express.json({ limit: '50mb' }));
12
+
13
+ // Connect to MongoDB
14
+ const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/buildtrack_db';
15
+ let isMongoConnected = false;
16
+ try {
17
+ await mongoose.connect(mongoURI, { serverSelectionTimeoutMS: 2000 });
18
+ isMongoConnected = true;
19
+ console.log('Connected to MongoDB');
20
+ } catch (error) {
21
+ console.error('Failed to connect to MongoDB, falling back to in-memory store', error.message);
22
+ }
23
+
24
+ // --- GENERIC NOSQL API ROUTES ---
25
+ // To smoothly transition from offline localStorage, we expose a generic collection API
26
+ const inMemoryDB: Record<string, any[]> = {};
27
+
28
+ app.get('/api/health', (req, res) => {
29
+ res.json({ status: 'ok', db: isMongoConnected ? mongoose.connection.readyState : 'in-memory' });
30
+ });
31
+
32
+ // --- AUTH ROUTES ---
33
+ const JWT_SECRET = process.env.JWT_SECRET || '5aeb6c98c7622543d05e89904d09aed7b33f45a616204b31d4a18de8397c3e7d';
34
+ const createLocalAvatar = (name: string) => {
35
+ const initials = name
36
+ .split(/\s+/)
37
+ .filter(Boolean)
38
+ .slice(0, 2)
39
+ .map(part => part[0]?.toUpperCase() || '')
40
+ .join('') || 'U';
41
+ return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="24" fill="#2563eb"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#fff">${initials}</text></svg>`)}`;
42
+ };
43
+
44
+ app.post('/api/auth/signup', async (req, res) => {
45
+ const { name, email, password, role } = req.body;
46
+ if (!email || !password || !name) return res.status(400).json({ error: "Missing fields" });
47
+
48
+ try {
49
+ const passwordHash = await bcrypt.hash(password, 10);
50
+ let user;
51
+
52
+ if (!isMongoConnected) {
53
+ if (!inMemoryDB['users']) inMemoryDB['users'] = [];
54
+ if (inMemoryDB['users'].find(u => u.email === email)) return res.status(400).json({ error: "User exists" });
55
+ user = { id: `user-${Date.now()}`, uid: `user-${Date.now()}`, name, email, passwordHash, role, avatar: createLocalAvatar(name), createdAt: new Date().toISOString() };
56
+ inMemoryDB['users'].push(user);
57
+ } else {
58
+ if (await mongoose.connection.db.collection('users').findOne({ email })) return res.status(400).json({ error: "User exists" });
59
+ user = { id: `user-${Date.now()}`, uid: `user-${Date.now()}`, name, email, passwordHash, role, avatar: createLocalAvatar(name), createdAt: new Date().toISOString() };
60
+ await mongoose.connection.db.collection('users').insertOne(user);
61
+ }
62
+
63
+ res.status(201).json({ message: "User created" });
64
+ } catch (e) {
65
+ res.status(500).json({ error: "Signup failed" });
66
+ }
67
+ });
68
+
69
+ app.post('/api/auth/login', async (req, res) => {
70
+ const { email, password } = req.body;
71
+ if (!email || !password) return res.status(400).json({ error: "Email and password required" });
72
+
73
+ try {
74
+ let user;
75
+ if (!isMongoConnected) {
76
+ user = (inMemoryDB['users'] || []).find(u => u.email === email);
77
+ } else {
78
+ user = await mongoose.connection.db.collection('users').findOne({ email });
79
+ }
80
+
81
+ if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
82
+ return res.status(401).json({ error: "Invalid credentials" });
83
+ }
84
+
85
+ const token = jwt.sign({ uid: user.uid, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
86
+ res.json({ token, user });
87
+ } catch (e) {
88
+ res.status(500).json({ error: "Login failed" });
89
+ }
90
+ });
91
+
92
+ app.get('/api/auth/me', async (req, res) => {
93
+ const authHeader = req.headers['authorization'];
94
+ const token = authHeader && authHeader.split(' ')[1];
95
+ if (!token) return res.status(401).json({ error: "Missing token" });
96
+
97
+ jwt.verify(token, JWT_SECRET, async (err: any, decoded: any) => {
98
+ if (err) return res.status(403).json({ error: "Invalid token" });
99
+ try {
100
+ let user;
101
+ if (!isMongoConnected) {
102
+ user = (inMemoryDB['users'] || []).find(u => u.uid === decoded.uid);
103
+ } else {
104
+ user = await mongoose.connection.db.collection('users').findOne({ uid: decoded.uid });
105
+ }
106
+ if (!user) return res.status(404).json({ error: "User not found" });
107
+ res.json({ user });
108
+ } catch (e) {
109
+ res.status(500).json({ error: "Server error" });
110
+ }
111
+ });
112
+ });
113
+
114
+ // --- JWT MIDDLEWARE ---
115
+ const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction) => {
116
+ const authHeader = req.headers['authorization'];
117
+ const token = authHeader && authHeader.split(' ')[1];
118
+ if (token == null) return res.status(401).json({ error: "Unauthorized" });
119
+
120
+ jwt.verify(token, JWT_SECRET, (err: any, user: any) => {
121
+ if (err) return res.status(403).json({ error: "Forbidden" });
122
+ (req as any).user = user;
123
+ next();
124
+ });
125
+ };
126
+
127
+ // Protect all API routes under /api/collections
128
+ app.use('/api/collections', authenticateToken);
129
+
130
+ app.get('/api/collections/:name', async (req, res) => {
131
+ try {
132
+ if (!isMongoConnected) {
133
+ return res.json(inMemoryDB[req.params.name] || []);
134
+ }
135
+ const docs = await mongoose.connection.db.collection(req.params.name).find().toArray();
136
+ res.json(docs);
137
+ } catch (e) {
138
+ res.status(500).json({ error: 'Failed to fetch collection' });
139
+ }
140
+ });
141
+
142
+ app.post('/api/collections/:name', async (req, res) => {
143
+ try {
144
+ if (!isMongoConnected) {
145
+ if (!inMemoryDB[req.params.name]) inMemoryDB[req.params.name] = [];
146
+ const doc = { ...req.body };
147
+ if (!doc.id) doc.id = Date.now().toString() + Math.random().toString();
148
+ inMemoryDB[req.params.name].push(doc);
149
+ return res.json(doc);
150
+ }
151
+ const doc = { ...req.body };
152
+ if (!doc.id) doc.id = new mongoose.Types.ObjectId().toString(); // Ensure string ID
153
+ await mongoose.connection.db.collection(req.params.name).insertOne(doc);
154
+ res.json(doc);
155
+ } catch (e) {
156
+ res.status(500).json({ error: 'Failed to insert document' });
157
+ }
158
+ });
159
+
160
+ app.put('/api/collections/:name/:id', async (req, res) => {
161
+ try {
162
+ if (!isMongoConnected) {
163
+ if (!inMemoryDB[req.params.name]) inMemoryDB[req.params.name] = [];
164
+ const idx = inMemoryDB[req.params.name].findIndex(d => d.id === req.params.id);
165
+ const updateData = { ...req.body };
166
+ if (idx >= 0) {
167
+ inMemoryDB[req.params.name][idx] = { ...inMemoryDB[req.params.name][idx], ...updateData };
168
+ } else {
169
+ inMemoryDB[req.params.name].push({ id: req.params.id, ...updateData });
170
+ }
171
+ return res.json({ id: req.params.id, ...updateData });
172
+ }
173
+ const updateData = { ...req.body };
174
+ delete updateData._id; // Prevent updating immutable _id if it sneaks in
175
+
176
+ await mongoose.connection.db.collection(req.params.name).updateOne(
177
+ { id: req.params.id },
178
+ { $set: updateData },
179
+ { upsert: true }
180
+ );
181
+ res.json({ id: req.params.id, ...updateData });
182
+ } catch (e) {
183
+ res.status(500).json({ error: 'Failed to update document' });
184
+ }
185
+ });
186
+
187
+ app.delete('/api/collections/:name/:id', async (req, res) => {
188
+ try {
189
+ if (!isMongoConnected) {
190
+ if (inMemoryDB[req.params.name]) {
191
+ inMemoryDB[req.params.name] = inMemoryDB[req.params.name].filter(d => d.id !== req.params.id);
192
+ }
193
+ return res.json({ success: true });
194
+ }
195
+ await mongoose.connection.db.collection(req.params.name).deleteOne({ id: req.params.id });
196
+ res.json({ success: true });
197
+ } catch (e) {
198
+ res.status(500).json({ error: 'Failed to delete document' });
199
+ }
200
+ });
201
+
202
+ // Vite integration
203
+ if (process.env.NODE_ENV !== 'production') {
204
+ const { createServer: createViteServer } = await import('vite');
205
+ const vite = await createViteServer({
206
+ server: { middlewareMode: true },
207
+ appType: 'custom',
208
+ });
209
+
210
+ app.get('/', async (req, res, next) => {
211
+ try {
212
+ const indexHtmlPath = path.join(process.cwd(), 'index.html');
213
+ const { readFile } = await import('node:fs/promises');
214
+ const template = await readFile(indexHtmlPath, 'utf-8');
215
+ const html = await vite.transformIndexHtml(req.originalUrl, template);
216
+ res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
217
+ } catch (error) {
218
+ vite.ssrFixStacktrace(error as Error);
219
+ next(error);
220
+ }
221
+ });
222
+
223
+ app.use(vite.middlewares);
224
+ } else {
225
+ const distPath = path.join(process.cwd(), 'dist');
226
+ app.use(express.static(distPath));
227
+ app.get(/.*/, (req, res) => {
228
+ res.sendFile(path.join(distPath, 'index.html'));
229
+ });
230
+ }
231
+
232
+ app.listen(PORT, '0.0.0.0', () => {
233
+ console.log(`Server running on http://localhost:${PORT}`);
234
+ });
235
+ }
236
+
237
+ startServer();