|
|
| import React, { useState, useMemo } from 'react'; |
| import { ProjectState, ProjectDocument, DPR, UserRole, MaterialConsumption, Unit } from '../types'; |
| import DocumentManager from './DocumentManager'; |
| import { MapPin, Users, Calendar, PlusCircle, X, ClipboardCheck, Lock, Sparkles, Loader2, FileText, CheckCircle2, Package, ArrowDownLeft, ArrowUpRight, Edit2, Save, HardHat, BarChart3, AlertCircle, Mic } from 'lucide-react'; |
| import { extractDPRData } from '../services/localAnalysisService'; |
| import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts'; |
|
|
| interface SiteExecutionProps { |
| data: ProjectState; |
| onAddDocument: (doc: ProjectDocument) => void; |
| onAddDPR: (dpr: DPR) => void; |
| onReceiveMaterial: (materialId: string, qty: number, rate?: number) => void; |
| onUpdatePDRemarks: (type: 'MATERIAL' | 'SUBCONTRACTOR', id: string, remarks: string) => void; |
| userRole: UserRole; |
| } |
|
|
| const SiteExecution: React.FC<SiteExecutionProps> = ({ data, onAddDocument, onAddDPR, onReceiveMaterial, onUpdatePDRemarks, userRole }) => { |
| const [isDprModalOpen, setIsDprModalOpen] = useState(false); |
| const [isReceiveModalOpen, setIsReceiveModalOpen] = useState(false); |
| const [isAiLoading, setIsAiLoading] = useState(false); |
| const [aiPopulatedFields, setAiPopulatedFields] = useState<Set<string>>(new Set()); |
| const [selectedReportId, setSelectedReportId] = useState<string>(''); |
| const [editingRemarksId, setEditingRemarksId] = useState<string | null>(null); |
| const [tempRemarks, setTempRemarks] = useState(''); |
| const [storeView, setStoreView] = useState<'INVENTORY' | 'HISTORY'>('INVENTORY'); |
| |
| const canAddDPR = userRole === 'ENGINEER' || userRole === 'DIRECTOR'; |
| const canUploadDoc = userRole === 'ENGINEER' || userRole === 'DIRECTOR'; |
| const canManageStore = userRole === 'ENGINEER' || userRole === 'MANAGER' || userRole === 'DIRECTOR'; |
| const isDirector = userRole === 'DIRECTOR'; |
|
|
| |
| const [activityDate, setActivityDate] = useState(new Date().toISOString().split('T')[0]); |
| const [activityDesc, setActivityDesc] = useState(''); |
| const [location, setLocation] = useState(''); |
| const [laborCount, setLaborCount] = useState(0); |
| const [remarks, setRemarks] = useState(''); |
| const [linkedBoqId, setLinkedBoqId] = useState(''); |
| const [subContractorId, setSubContractorId] = useState(''); |
| const [workDoneQty, setWorkDoneQty] = useState(0); |
| const [materialsConsumed, setMaterialsConsumed] = useState<MaterialConsumption[]>([]); |
|
|
| |
| const [receiveMatId, setReceiveMatId] = useState(''); |
| const [receiveQty, setReceiveQty] = useState(''); |
| const [receiveRate, setReceiveRate] = useState(''); |
|
|
| const reportDocs = useMemo(() => |
| data.documents |
| .filter(d => d.category === 'REPORT') |
| .sort((a, b) => new Date(b.uploadDate).getTime() - new Date(a.uploadDate).getTime()), |
| [data.documents] |
| ); |
|
|
| |
| const stockChartData = useMemo(() => { |
| return data.materials.map(m => ({ |
| name: m.name.length > 12 ? m.name.substring(0, 10) + '..' : m.name, |
| fullName: m.name, |
| Received: m.totalReceived, |
| Consumed: m.totalConsumed, |
| unit: m.unit |
| })); |
| }, [data.materials]); |
|
|
| |
| const consumptionHistory = useMemo(() => { |
| const history: { id: string; date: string; materialName: string; qty: number; unit: string; activity: string }[] = []; |
| data.dprs.forEach(dpr => { |
| if (dpr.materialsUsed) { |
| dpr.materialsUsed.forEach(usage => { |
| const mat = data.materials.find(m => m.id === usage.materialId); |
| if (mat) { |
| history.push({ |
| id: `${dpr.id}-${mat.id}`, |
| date: dpr.date, |
| materialName: mat.name, |
| qty: usage.qty, |
| unit: mat.unit, |
| activity: dpr.activity |
| }); |
| } |
| }); |
| } |
| }); |
| return history.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); |
| }, [data.dprs, data.materials]); |
|
|
| const handleCreateDPR = (e: React.FormEvent) => { |
| e.preventDefault(); |
| |
| |
| const invalidConsumption = materialsConsumed.find(c => { |
| const mat = data.materials.find(m => m.id === c.materialId); |
| return mat && c.qty > mat.currentStock; |
| }); |
|
|
| if (invalidConsumption) { |
| const mat = data.materials.find(m => m.id === invalidConsumption.materialId); |
| alert(`Insufficient stock for ${mat?.name}. Available: ${mat?.currentStock} ${mat?.unit}, Requested: ${invalidConsumption.qty} ${mat?.unit}. Please receive material first.`); |
| return; |
| } |
|
|
| let finalDesc = activityDesc; |
| if (linkedBoqId && !finalDesc) { |
| const boqItem = data.boq.find(b => b.id === linkedBoqId); |
| if (boqItem) finalDesc = boqItem.description; |
| } |
|
|
| const newDPR: DPR = { |
| id: `DPR-${Date.now()}`, |
| date: activityDate, |
| activity: finalDesc || 'Site Activity', |
| location: location || 'Site', |
| laborCount: Number(laborCount), |
| remarks, |
| linkedBoqId: linkedBoqId || undefined, |
| subContractorId: subContractorId || undefined, |
| workDoneQty: Number(workDoneQty) > 0 ? Number(workDoneQty) : undefined, |
| materialsUsed: materialsConsumed.filter(m => m.qty > 0) |
| }; |
|
|
| onAddDPR(newDPR); |
| setIsDprModalOpen(false); |
| resetForm(); |
| }; |
|
|
| const handleReceiveSubmit = (e: React.FormEvent) => { |
| e.preventDefault(); |
| if(receiveMatId && receiveQty) { |
| onReceiveMaterial(receiveMatId, Number(receiveQty), receiveRate ? Number(receiveRate) : undefined); |
| setIsReceiveModalOpen(false); |
| setReceiveMatId(''); |
| setReceiveQty(''); |
| setReceiveRate(''); |
| } |
| }; |
|
|
| const resetForm = () => { |
| setActivityDesc(''); |
| setLocation(''); |
| setLaborCount(0); |
| setRemarks(''); |
| setLinkedBoqId(''); |
| setSubContractorId(''); |
| setWorkDoneQty(0); |
| setAiPopulatedFields(new Set()); |
| setSelectedReportId(''); |
| setMaterialsConsumed([]); |
| }; |
|
|
| |
| const [isRecording, setIsRecording] = useState(false); |
| |
| const handleVoiceRecord = () => { |
| const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; |
| if (!SpeechRecognition) { |
| alert("Voice recognition is not supported in this browser."); |
| return; |
| } |
|
|
| const recognition = new SpeechRecognition(); |
| recognition.continuous = false; |
| recognition.interimResults = false; |
| recognition.lang = 'en-US'; |
|
|
| recognition.onstart = () => { |
| setIsRecording(true); |
| }; |
|
|
| recognition.onresult = async (event: any) => { |
| setIsRecording(false); |
| const transcript = event.results[0][0].transcript; |
| if (transcript) { |
| setIsAiLoading(true); |
| |
| const extracted = await extractDPRData("Voice Note", data.boq, transcript, 'text/plain'); |
| handleExtractionResult(extracted); |
| } |
| }; |
|
|
| recognition.onerror = (event: any) => { |
| console.error("Speech recognition error", event.error); |
| setIsRecording(false); |
| if (event.error === 'not-allowed') { |
| alert("Please grant microphone permissions to use voice dictation."); |
| } else { |
| |
| alert(`Microphone error: ${event.error}. Note: If inside an iframe without mic access, please open in a new tab.`); |
| } |
| }; |
|
|
| recognition.start(); |
| }; |
|
|
| const handleExtractionResult = (extracted: any) => { |
| setIsAiLoading(false); |
| if (extracted) { |
| const newPopulated = new Set<string>(); |
| if (extracted.date) { setActivityDate(extracted.date); newPopulated.add('date'); } |
| if (extracted.activity) { setActivityDesc(extracted.activity); newPopulated.add('activity'); } |
| if (extracted.location) { setLocation(extracted.location); newPopulated.add('location'); } |
| if (extracted.laborCount) { setLaborCount(extracted.laborCount); newPopulated.add('labor'); } |
| if (extracted.remarks) { setRemarks(extracted.remarks); newPopulated.add('remarks'); } |
| if (extracted.linkedBoqId) { setLinkedBoqId(extracted.linkedBoqId); newPopulated.add('boq'); } |
| if (extracted.workDoneQty) { setWorkDoneQty(extracted.workDoneQty); newPopulated.add('qty'); } |
| |
| if (extracted.subContractorName) { |
| const match = data.subContractors?.find(s => |
| s.name.toLowerCase().includes(extracted.subContractorName!.toLowerCase()) || |
| extracted.subContractorName!.toLowerCase().includes(s.name.toLowerCase()) |
| ); |
| if (match) { |
| setSubContractorId(match.id); |
| newPopulated.add('subcontractor'); |
| } |
| } |
|
|
| if (extracted.materials && extracted.materials.length > 0) { |
| const mappedMaterials = extracted.materials.map((m: any) => { |
| const match = data.materials.find(ex => ex.name.toLowerCase().includes(m.name.toLowerCase())); |
| return match ? { materialId: match.id, qty: m.qty } : null; |
| }).filter(Boolean) as MaterialConsumption[]; |
| |
| if (mappedMaterials.length > 0) { |
| setMaterialsConsumed(mappedMaterials); |
| newPopulated.add('materials'); |
| } |
| } |
| |
| setAiPopulatedFields(newPopulated); |
| } |
| }; |
|
|
| const handleAiAutoFill = async () => { |
| const reportToAnalyze = selectedReportId |
| ? reportDocs.find(d => d.id === selectedReportId) |
| : reportDocs[0]; |
|
|
| if (!reportToAnalyze) { |
| alert("Please upload or select a site report to analyze."); |
| return; |
| } |
|
|
| setIsAiLoading(true); |
| const extracted = await extractDPRData(reportToAnalyze.name, data.boq); |
| handleExtractionResult(extracted); |
| }; |
|
|
| const addConsumptionRow = () => { |
| if (data.materials.length > 0) { |
| setMaterialsConsumed([...materialsConsumed, { materialId: data.materials[0].id, qty: 0 }]); |
| } |
| }; |
|
|
| const updateConsumption = (index: number, field: keyof MaterialConsumption, value: any) => { |
| const updated = [...materialsConsumed]; |
| updated[index] = { ...updated[index], [field]: value }; |
| setMaterialsConsumed(updated); |
| }; |
|
|
| const removeConsumptionRow = (index: number) => { |
| setMaterialsConsumed(materialsConsumed.filter((_, i) => i !== index)); |
| }; |
|
|
| const saveRemarks = (type: 'MATERIAL' | 'SUBCONTRACTOR', id: string) => { |
| onUpdatePDRemarks(type, id, tempRemarks); |
| setEditingRemarksId(null); |
| }; |
|
|
| return ( |
| <div className="space-y-8"> |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> |
| <div> |
| <h1 className="text-2xl font-bold text-slate-800">Site Execution</h1> |
| <p className="text-slate-500">Track Progress, Remaining Works & Daily Reports</p> |
| </div> |
| {canAddDPR ? ( |
| <button |
| onClick={() => setIsDprModalOpen(true)} |
| 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" |
| > |
| <ClipboardCheck className="w-4 h-4" /> |
| Add Daily Progress |
| </button> |
| ) : ( |
| <div className="flex items-center gap-2 text-slate-400 bg-slate-100 px-3 py-1.5 rounded-lg text-sm"> |
| <Lock className="w-3 h-3" /> |
| <span>Read Only (Role: {userRole})</span> |
| </div> |
| )} |
| </div> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> |
| {/* Site Store Section */} |
| <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-full flex flex-col"> |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-indigo-50/50"> |
| <div className="flex items-center gap-2"> |
| <Package className="w-5 h-5 text-indigo-600" /> |
| <h3 className="font-semibold text-slate-800">Site Store</h3> |
| </div> |
| |
| <div className="flex items-center gap-3"> |
| <div className="flex bg-slate-200/50 p-1 rounded-lg"> |
| <button |
| onClick={() => setStoreView('INVENTORY')} |
| 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'}`} |
| > |
| Stock |
| </button> |
| <button |
| onClick={() => setStoreView('HISTORY')} |
| 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'}`} |
| > |
| Log |
| </button> |
| </div> |
| |
| {canManageStore && ( |
| <button |
| onClick={() => setIsReceiveModalOpen(true)} |
| 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" |
| > |
| <ArrowDownLeft className="w-3.5 h-3.5" /> |
| Inward |
| </button> |
| )} |
| </div> |
| </div> |
| |
| {storeView === 'INVENTORY' ? ( |
| <> |
| {/* Material Chart */} |
| {stockChartData.length > 0 && ( |
| <div className="px-6 py-4 border-b border-slate-100 bg-slate-50/30"> |
| <div className="h-48 w-full"> |
| <ResponsiveContainer width="100%" height="100%"> |
| <BarChart data={stockChartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}> |
| <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" /> |
| <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} interval={0} /> |
| <YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} /> |
| <Tooltip |
| contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} |
| cursor={{ fill: '#f1f5f9' }} |
| formatter={(value: number, name: string, props: any) => [`${value.toLocaleString()} ${props.payload.unit}`, name]} |
| labelFormatter={(label) => { |
| const item = stockChartData.find(d => d.name === label); |
| return item ? item.fullName : label; |
| }} |
| /> |
| <Legend wrapperStyle={{ fontSize: '10px' }} /> |
| <Bar dataKey="Received" fill="#6366f1" radius={[4, 4, 0, 0]} barSize={20} /> |
| <Bar dataKey="Consumed" fill="#f59e0b" radius={[4, 4, 0, 0]} barSize={20} /> |
| </BarChart> |
| </ResponsiveContainer> |
| </div> |
| </div> |
| )} |
| |
| <div className="p-4 space-y-3 flex-1 overflow-y-auto max-h-[400px]"> |
| {data.materials.map(mat => ( |
| <div key={mat.id} className="border border-slate-200 rounded-lg p-3 hover:shadow-sm transition-shadow"> |
| <div className="flex justify-between items-start mb-1"> |
| <h4 className="font-bold text-slate-700 text-sm">{mat.name}</h4> |
| <span className="text-[10px] font-bold bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded">{mat.unit}</span> |
| </div> |
| <div className="grid grid-cols-3 gap-2 text-xs mb-2"> |
| <div> |
| <span className="text-slate-400 block">Total Recv</span> |
| <span className="text-slate-600 font-medium">{mat.totalReceived.toLocaleString()}</span> |
| </div> |
| <div> |
| <span className="text-slate-400 block">Consumed</span> |
| <span className="text-slate-600 font-medium">{mat.totalConsumed.toLocaleString()}</span> |
| </div> |
| <div> |
| <span className="text-slate-400 block">Stock</span> |
| <span className="font-bold text-indigo-600">{mat.currentStock.toLocaleString()}</span> |
| </div> |
| </div> |
| <div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden"> |
| <div className="h-full bg-indigo-500" style={{ width: `${Math.min(100, (mat.currentStock / (mat.totalReceived || 1)) * 100)}%` }}></div> |
| </div> |
| {/* Director Remarks */} |
| <div className="mt-2 pt-2 border-t border-slate-100"> |
| {editingRemarksId === mat.id ? ( |
| <div className="flex items-center gap-1"> |
| <input |
| type="text" |
| value={tempRemarks} |
| onChange={(e) => setTempRemarks(e.target.value)} |
| className="w-full text-[10px] border border-indigo-300 rounded px-1 py-0.5 outline-none" |
| autoFocus |
| /> |
| <button onClick={() => saveRemarks('MATERIAL', mat.id)} className="text-emerald-600"><Save className="w-3 h-3"/></button> |
| <button onClick={() => setEditingRemarksId(null)} className="text-red-500"><X className="w-3 h-3"/></button> |
| </div> |
| ) : ( |
| <div className="flex items-start gap-1 group/remark"> |
| <p className="text-[10px] text-slate-400 italic flex-1 truncate"> |
| {mat.pdRemarks ? `Note: ${mat.pdRemarks}` : (isDirector ? "Add PD Note..." : "")} |
| </p> |
| {isDirector && ( |
| <button |
| onClick={() => { setEditingRemarksId(mat.id); setTempRemarks(mat.pdRemarks || ''); }} |
| className="opacity-0 group-hover/remark:opacity-100 text-slate-400 hover:text-indigo-600 transition-opacity" |
| > |
| <Edit2 className="w-2.5 h-2.5" /> |
| </button> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
| {data.materials.length === 0 && ( |
| <div className="text-center py-8 text-slate-400 text-sm">No materials tracked.</div> |
| )} |
| </div> |
| </> |
| ) : ( |
| <div className="flex-1 overflow-y-auto max-h-[600px] p-0"> |
| <table className="w-full text-left text-xs"> |
| <thead className="bg-slate-50 text-slate-500 font-semibold border-b border-slate-100 sticky top-0"> |
| <tr> |
| <th className="px-4 py-2">Date</th> |
| <th className="px-4 py-2">Material</th> |
| <th className="px-4 py-2 text-right">Qty</th> |
| <th className="px-4 py-2">Activity</th> |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-slate-50"> |
| {consumptionHistory.map(row => ( |
| <tr key={row.id} className="hover:bg-slate-50"> |
| <td className="px-4 py-2 text-slate-500 whitespace-nowrap">{row.date}</td> |
| <td className="px-4 py-2 font-medium text-slate-700">{row.materialName}</td> |
| <td className="px-4 py-2 text-right font-mono text-indigo-600">{row.qty} {row.unit}</td> |
| <td className="px-4 py-2 text-slate-500 truncate max-w-[150px]" title={row.activity}>{row.activity}</td> |
| </tr> |
| ))} |
| {consumptionHistory.length === 0 && ( |
| <tr> |
| <td colSpan={4} className="p-8 text-center text-slate-400">No consumption records found in DPRs.</td> |
| </tr> |
| )} |
| </tbody> |
| </table> |
| </div> |
| )} |
| </div> |
| |
| {/* Engaged Sub-Contractors Section */} |
| <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-full"> |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-orange-50/50"> |
| <div className="flex items-center gap-2"> |
| <HardHat className="w-5 h-5 text-orange-600" /> |
| <h3 className="font-semibold text-slate-800">Engaged Sub-Contractors</h3> |
| </div> |
| <span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-bold uppercase">Automated from Progress</span> |
| </div> |
| <div className="p-4 space-y-3"> |
| {data.subContractors && data.subContractors.map(sc => ( |
| <div key={sc.id} className="border border-slate-200 rounded-lg p-3 hover:shadow-sm transition-shadow"> |
| <div className="flex justify-between items-start mb-1"> |
| <div> |
| <h4 className="font-bold text-slate-700 text-sm">{sc.name}</h4> |
| <p className="text-[10px] text-slate-500">{sc.specialization}</p> |
| </div> |
| <span className="text-[10px] font-bold bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded border border-orange-100"> |
| Due: ৳{sc.currentLiability.toLocaleString()} |
| </span> |
| </div> |
| <div className="grid grid-cols-2 gap-2 text-xs mt-2 mb-1"> |
| <div className="bg-slate-50 p-1.5 rounded border border-slate-100"> |
| <span className="text-slate-400 block text-[10px] uppercase">Work Done Value</span> |
| <span className="text-slate-700 font-bold">৳{sc.totalWorkValue.toLocaleString()}</span> |
| </div> |
| <div className="bg-slate-50 p-1.5 rounded border border-slate-100"> |
| <span className="text-slate-400 block text-[10px] uppercase">Paid / Billed</span> |
| <span className="text-slate-700 font-bold">৳{sc.totalBilled.toLocaleString()}</span> |
| </div> |
| </div> |
| {/* Director Remarks */} |
| <div className="mt-1 pt-1 border-t border-slate-100"> |
| {editingRemarksId === sc.id ? ( |
| <div className="flex items-center gap-1"> |
| <input |
| type="text" |
| value={tempRemarks} |
| onChange={(e) => setTempRemarks(e.target.value)} |
| className="w-full text-[10px] border border-orange-300 rounded px-1 py-0.5 outline-none" |
| autoFocus |
| /> |
| <button onClick={() => saveRemarks('SUBCONTRACTOR', sc.id)} className="text-emerald-600"><Save className="w-3 h-3"/></button> |
| <button onClick={() => setEditingRemarksId(null)} className="text-red-500"><X className="w-3 h-3"/></button> |
| </div> |
| ) : ( |
| <div className="flex items-start gap-1 group/remark"> |
| <p className="text-[10px] text-slate-400 italic flex-1 truncate"> |
| {sc.pdRemarks ? `Note: ${sc.pdRemarks}` : (isDirector ? "Add PD Note..." : "")} |
| </p> |
| {isDirector && ( |
| <button |
| onClick={() => { setEditingRemarksId(sc.id); setTempRemarks(sc.pdRemarks || ''); }} |
| className="opacity-0 group-hover/remark:opacity-100 text-slate-400 hover:text-orange-600 transition-opacity" |
| > |
| <Edit2 className="w-2.5 h-2.5" /> |
| </button> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
| {(!data.subContractors || data.subContractors.length === 0) && ( |
| <div className="text-center py-8 text-slate-400 text-sm">No sub-contractors engaged.</div> |
| )} |
| </div> |
| </div> |
| </div> |
|
|
| <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden mb-8"> |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50"> |
| <h3 className="font-semibold text-slate-800">Physical Progress & Remaining Works</h3> |
| <span className="text-xs font-medium text-slate-500 bg-white border border-slate-200 px-3 py-1 rounded-full"> |
| Live from Site |
| </span> |
| </div> |
| <div className="overflow-x-auto"> |
| <table className="w-full text-left text-sm"> |
| <thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200"> |
| <tr> |
| <th className="px-6 py-4 w-1/3">Item Description</th> |
| <th className="px-6 py-4 text-right">Planned Qty</th> |
| <th className="px-6 py-4 text-right">Executed Qty</th> |
| <th className="px-6 py-4 text-right font-semibold text-blue-700 bg-blue-50/50">Remaining Qty</th> |
| <th className="px-6 py-4 text-right">Progress %</th> |
| <th className="px-6 py-4 text-center">Status</th> |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-slate-100"> |
| {data.boq.map((item) => { |
| const percent = Math.min(100, Math.round((item.executedQty / item.plannedQty) * 100)); |
| const remaining = Math.max(0, item.plannedQty - item.executedQty); |
| return ( |
| <tr key={item.id} className="hover:bg-slate-50 transition-colors"> |
| <td className="px-6 py-4 font-medium text-slate-700"> |
| {item.description} |
| <div className="text-xs text-slate-400 font-normal mt-0.5">ID: {item.id}</div> |
| </td> |
| <td className="px-6 py-4 text-right text-slate-500">{item.plannedQty.toLocaleString()} <span className="text-xs">{item.unit}</span></td> |
| <td className="px-6 py-4 text-right text-slate-900 font-medium">{item.executedQty.toLocaleString()} <span className="text-xs">{item.unit}</span></td> |
| <td className="px-6 py-4 text-right font-bold text-blue-600 bg-blue-50/30"> |
| {remaining.toLocaleString()} <span className="text-xs font-normal text-blue-400">{item.unit}</span> |
| </td> |
| <td className="px-6 py-4 text-right"> |
| <div className="flex flex-col items-end gap-1"> |
| <span className="text-xs font-bold text-slate-700">{percent}%</span> |
| <div className="w-24 bg-slate-200 h-1.5 rounded-full overflow-hidden"> |
| <div className={`h-full rounded-full ${percent >= 100 ? 'bg-emerald-500' : 'bg-blue-600'}`} style={{ width: `${percent}%` }}></div> |
| </div> |
| </div> |
| </td> |
| <td className="px-6 py-4 text-center"> |
| {percent >= 100 ? ( |
| <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"> |
| Completed |
| </span> |
| ) : percent > 0 ? ( |
| <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"> |
| In Progress |
| </span> |
| ) : ( |
| <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"> |
| Pending |
| </span> |
| )} |
| </td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| </div> |
| </div> |
|
|
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> |
| {/* DPRs */} |
| <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden h-fit"> |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50"> |
| <h3 className="font-semibold text-slate-800">Daily Progress Reports (DPR) Log</h3> |
| </div> |
| <div className="divide-y divide-slate-100 max-h-[400px] overflow-y-auto"> |
| {data.dprs.map((dpr) => ( |
| <div key={dpr.id} className="p-5 hover:bg-slate-50 transition-colors group"> |
| <div className="flex justify-between items-start mb-2"> |
| <div className="flex items-center gap-2"> |
| <span className="w-2 h-2 rounded-full bg-blue-500"></span> |
| <h4 className="font-medium text-slate-900 text-sm group-hover:text-blue-600 transition-colors">{dpr.activity}</h4> |
| </div> |
| <span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">#{dpr.id}</span> |
| </div> |
| |
| <div className="flex items-center gap-4 mt-2 text-xs text-slate-500"> |
| <div className="flex items-center gap-1.5 bg-slate-50 px-2 py-1 rounded"> |
| <Calendar className="w-3.5 h-3.5 text-slate-400" /> |
| <span>{dpr.date}</span> |
| </div> |
| <div className="flex items-center gap-1.5 bg-slate-50 px-2 py-1 rounded"> |
| <MapPin className="w-3.5 h-3.5 text-slate-400" /> |
| <span>{dpr.location}</span> |
| </div> |
| <div className="flex items-center gap-1.5 bg-slate-50 px-2 py-1 rounded"> |
| <Users className="w-3.5 h-3.5 text-slate-400" /> |
| <span>{dpr.laborCount} Workers</span> |
| </div> |
| </div> |
| |
| {dpr.workDoneQty && dpr.linkedBoqId && ( |
| <div className="mt-2 text-xs font-medium text-emerald-600 bg-emerald-50 w-fit px-2 py-0.5 rounded"> |
| + {dpr.workDoneQty} Work Done Added |
| </div> |
| )} |
| |
| {dpr.subContractorId && ( |
| <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"> |
| Engaged: {data.subContractors?.find(s => s.id === dpr.subContractorId)?.name} |
| </div> |
| )} |
| |
| {dpr.materialsUsed && dpr.materialsUsed.length > 0 && ( |
| <div className="mt-2 flex flex-wrap gap-2"> |
| {dpr.materialsUsed.map((usage, idx) => { |
| const matName = data.materials.find(m => m.id === usage.materialId)?.name || usage.materialId; |
| return ( |
| <span key={idx} className="text-[10px] bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded border border-indigo-100"> |
| Consumed: {usage.qty} {data.materials.find(m => m.id === usage.materialId)?.unit} of {matName} |
| </span> |
| ); |
| })} |
| </div> |
| )} |
| |
| {dpr.remarks && ( |
| <div className="mt-3 text-xs text-slate-600 italic border-l-2 border-slate-200 pl-3"> |
| "{dpr.remarks}" |
| </div> |
| )} |
| </div> |
| ))} |
| {data.dprs.length === 0 && ( |
| <div className="p-8 text-center text-slate-400 text-sm">No daily reports logged yet.</div> |
| )} |
| </div> |
| </div> |
| |
| {/* Documents */} |
| <DocumentManager |
| documents={data.documents} |
| onAddDocument={onAddDocument} |
| filterModule="SITE" |
| compact={true} |
| allowUpload={canUploadDoc} |
| /> |
| </div> |
|
|
| {} |
| {isReceiveModalOpen && ( |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> |
| <div className="bg-white rounded-xl shadow-xl w-full max-w-sm overflow-hidden"> |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-indigo-50/50"> |
| <h3 className="font-semibold text-slate-800">Receive Material (Inward)</h3> |
| <button onClick={() => setIsReceiveModalOpen(false)}><X className="w-5 h-5 text-slate-400" /></button> |
| </div> |
| <form onSubmit={handleReceiveSubmit} className="p-6 space-y-4"> |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1">Select Material</label> |
| <select |
| required |
| value={receiveMatId} |
| onChange={(e) => setReceiveMatId(e.target.value)} |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm outline-none" |
| > |
| <option value="">-- Choose --</option> |
| {data.materials.map(m => <option key={m.id} value={m.id}>{m.name} ({m.unit})</option>)} |
| </select> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1">Quantity Received</label> |
| <input |
| type="number" |
| required |
| min="0" |
| step="0.01" |
| value={receiveQty} |
| onChange={(e) => setReceiveQty(e.target.value)} |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm outline-none" |
| placeholder="e.g. 500" |
| /> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1">Buying Rate (Optional)</label> |
| <input |
| type="number" |
| min="0" |
| step="0.01" |
| value={receiveRate} |
| onChange={(e) => setReceiveRate(e.target.value)} |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm outline-none" |
| placeholder="Update Unit Price" |
| /> |
| </div> |
| <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> |
| </form> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {isDprModalOpen && ( |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> |
| <div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]"> |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50/30"> |
| <h3 className="font-semibold text-slate-800">Add Daily Progress Report</h3> |
| <button onClick={() => setIsDprModalOpen(false)} className="text-slate-400 hover:text-slate-600"> |
| <X className="w-5 h-5" /> |
| </button> |
| </div> |
| |
| <div className="p-4 bg-indigo-50/50 border-b border-indigo-100 flex flex-col gap-3"> |
| <div className="flex items-center justify-between"> |
| <div className="flex items-center gap-2 text-indigo-700 font-bold text-xs uppercase tracking-wider"> |
| <Sparkles className="w-3.5 h-3.5" /> |
| AI Smart Auto-Fill |
| </div> |
| {isAiLoading && ( |
| <div className="flex items-center gap-1.5 text-indigo-600 text-[10px] font-bold animate-pulse"> |
| <Loader2 className="w-3 h-3 animate-spin" /> Analyzing Report... |
| </div> |
| )} |
| </div> |
| <div className="flex items-center gap-2"> |
| <div className="relative flex-1"> |
| <FileText className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> |
| <select |
| value={selectedReportId} |
| onChange={(e) => setSelectedReportId(e.target.value)} |
| 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" |
| > |
| <option value="">{reportDocs.length > 0 ? '-- Use Latest Report --' : 'No reports available'}</option> |
| {reportDocs.map(doc => ( |
| <option key={doc.id} value={doc.id}>{doc.name} ({doc.uploadDate})</option> |
| ))} |
| </select> |
| </div> |
| <button |
| onClick={handleAiAutoFill} |
| disabled={isAiLoading || reportDocs.length === 0} |
| 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" |
| > |
| Auto-Fill |
| </button> |
| <button |
| onClick={(e) => { e.preventDefault(); handleVoiceRecord(); }} |
| disabled={isRecording || isAiLoading} |
| 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'}`} |
| title="Dictate Daily Progress" |
| > |
| <Mic className="w-3.5 h-3.5" /> |
| {isRecording ? 'Listening...' : 'Dictate'} |
| </button> |
| </div> |
| {aiPopulatedFields.size > 0 && ( |
| <div className="text-[10px] text-indigo-600 font-medium flex items-center gap-1.5"> |
| <CheckCircle2 className="w-3 h-3" /> |
| Successfully updated {aiPopulatedFields.size} fields |
| </div> |
| )} |
| </div> |
| |
| <form onSubmit={handleCreateDPR} className="p-6 space-y-4 overflow-y-auto"> |
| <div className="grid grid-cols-2 gap-4"> |
| <div className="relative"> |
| <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1"> |
| Date |
| {aiPopulatedFields.has('date') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <input |
| type="date" |
| required |
| value={activityDate} |
| onChange={(e) => { |
| setActivityDate(e.target.value); |
| const next = new Set(aiPopulatedFields); next.delete('date'); setAiPopulatedFields(next); |
| }} |
| 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'}`} |
| /> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1"> |
| Labor Count |
| {aiPopulatedFields.has('labor') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <input |
| type="number" |
| min="0" |
| value={laborCount} |
| onChange={(e) => { |
| setLaborCount(Number(e.target.value)); |
| const next = new Set(aiPopulatedFields); next.delete('labor'); setAiPopulatedFields(next); |
| }} |
| 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'}`} |
| /> |
| </div> |
| </div> |
| |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1"> |
| Link to BOQ Item (Activity Type) |
| {aiPopulatedFields.has('boq') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <select |
| value={linkedBoqId} |
| onChange={(e) => { |
| setLinkedBoqId(e.target.value); |
| const next = new Set(aiPopulatedFields); next.delete('boq'); setAiPopulatedFields(next); |
| }} |
| 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'}`} |
| > |
| <option value="">-- General Activity (No BOQ Update) --</option> |
| {data.boq.map(item => ( |
| <option key={item.id} value={item.id}> |
| {item.id} - {item.description.substring(0, 50)}... |
| </option> |
| ))} |
| </select> |
| </div> |
| |
| {(linkedBoqId || aiPopulatedFields.has('qty')) && ( |
| <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'}`}> |
| <div> |
| <label className="block text-sm font-medium text-blue-800 mb-1 flex items-center gap-1"> |
| Work Done Today (Quantity) |
| {aiPopulatedFields.has('qty') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <div className="flex items-center gap-2"> |
| <input |
| type="number" |
| min="0" |
| step="0.01" |
| required |
| value={workDoneQty} |
| onChange={(e) => { |
| setWorkDoneQty(Number(e.target.value)); |
| const next = new Set(aiPopulatedFields); next.delete('qty'); setAiPopulatedFields(next); |
| }} |
| 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'}`} |
| /> |
| <span className="text-sm font-medium text-blue-600"> |
| {data.boq.find(b => b.id === linkedBoqId)?.unit} |
| </span> |
| </div> |
| </div> |
| |
| <div> |
| <label className="block text-xs font-bold text-slate-500 uppercase mb-1 flex items-center gap-1"> |
| Engaged Sub-Contractor |
| {aiPopulatedFields.has('subcontractor') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <select |
| value={subContractorId} |
| onChange={(e) => { |
| setSubContractorId(e.target.value); |
| const next = new Set(aiPopulatedFields); next.delete('subcontractor'); setAiPopulatedFields(next); |
| }} |
| 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'}`} |
| > |
| <option value="">-- No Sub-Contractor (Direct Labor) --</option> |
| {data.subContractors?.map(sc => ( |
| <option key={sc.id} value={sc.id}> |
| {sc.name} - (Rate: ৳{sc.agreedRates.find(r => r.boqId === linkedBoqId)?.rate || 0}) |
| </option> |
| ))} |
| </select> |
| {subContractorId && linkedBoqId && ( |
| <div className="text-[10px] text-orange-600 mt-1"> |
| * Liability will be automatically created: ৳{(Number(workDoneQty) * (data.subContractors?.find(s => s.id === subContractorId)?.agreedRates.find(r => r.boqId === linkedBoqId)?.rate || 0)).toLocaleString()} |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| |
| {/* Material Consumption Section */} |
| <div className="bg-slate-50 p-4 rounded-lg border border-slate-200"> |
| <div className="flex justify-between items-center mb-2"> |
| <div className="flex items-center gap-2"> |
| <label className="text-xs font-bold text-slate-600 uppercase">Materials Consumed Today</label> |
| {aiPopulatedFields.has('materials') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </div> |
| <button type="button" onClick={addConsumptionRow} className="text-indigo-600 text-xs font-bold flex items-center gap-1"> |
| <PlusCircle className="w-3 h-3" /> Add Material |
| </button> |
| </div> |
| <div className="space-y-2"> |
| {materialsConsumed.map((row, idx) => { |
| const mat = data.materials.find(m => m.id === row.materialId); |
| const isError = mat ? row.qty > mat.currentStock : false; |
| |
| return ( |
| <div key={idx} className="flex gap-2 items-center"> |
| <div className="flex-1 flex flex-col"> |
| <select |
| value={row.materialId} |
| onChange={(e) => updateConsumption(idx, 'materialId', e.target.value)} |
| className={`text-xs border rounded px-2 py-1.5 ${aiPopulatedFields.has('materials') ? 'border-indigo-200 bg-indigo-50/20' : 'border-slate-300'}`} |
| > |
| {data.materials.map(m => <option key={m.id} value={m.id}>{m.name} (Stock: {m.currentStock})</option>)} |
| </select> |
| </div> |
| <div className="w-24 relative"> |
| <input |
| type="number" |
| value={row.qty} |
| onChange={(e) => updateConsumption(idx, 'qty', Number(e.target.value))} |
| 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')}`} |
| placeholder="Qty" |
| /> |
| {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>} |
| </div> |
| <span className="text-xs text-slate-500 w-8">{mat?.unit}</span> |
| <button type="button" onClick={() => removeConsumptionRow(idx)} className="text-red-500 hover:bg-red-50 p-1 rounded"><X className="w-3 h-3"/></button> |
| </div> |
| ); |
| })} |
| {materialsConsumed.length === 0 && <p className="text-xs text-slate-400 italic">No materials added.</p>} |
| </div> |
| </div> |
| |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1"> |
| Activity Description |
| {aiPopulatedFields.has('activity') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <input |
| type="text" |
| value={activityDesc} |
| onChange={(e) => { |
| setActivityDesc(e.target.value); |
| const next = new Set(aiPopulatedFields); next.delete('activity'); setAiPopulatedFields(next); |
| }} |
| placeholder={linkedBoqId ? "Auto-filled from BOQ if empty" : "e.g., Site Clearing, Mobilization"} |
| 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'}`} |
| /> |
| </div> |
| |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1"> |
| Location |
| {aiPopulatedFields.has('location') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <input |
| type="text" |
| value={location} |
| onChange={(e) => { |
| setLocation(e.target.value); |
| const next = new Set(aiPopulatedFields); next.delete('location'); setAiPopulatedFields(next); |
| }} |
| placeholder="e.g., Chainage 10+500" |
| 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'}`} |
| /> |
| </div> |
| |
| <div> |
| <label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-1"> |
| Remarks / Issues |
| {aiPopulatedFields.has('remarks') && <Sparkles className="w-2.5 h-2.5 text-indigo-500" />} |
| </label> |
| <textarea |
| rows={3} |
| value={remarks} |
| onChange={(e) => { |
| setRemarks(e.target.value); |
| const next = new Set(aiPopulatedFields); next.delete('remarks'); setAiPopulatedFields(next); |
| }} |
| 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'}`} |
| placeholder="Any notes..." |
| /> |
| </div> |
| |
| <div className="pt-2 flex justify-end gap-3 border-t border-slate-100 mt-2 pt-4"> |
| <button |
| type="button" |
| onClick={() => { setIsDprModalOpen(false); resetForm(); }} |
| className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors" |
| > |
| Cancel |
| </button> |
| <button |
| type="submit" |
| 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" |
| > |
| Save Progress |
| </button> |
| </div> |
| </form> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| export default SiteExecution; |
|
|