Download components/MasterControl.tsx from boka773/ConstructionProcectmanagement: direct link, hf CLI and curl.
- Browser
- Download file 55.2 kB
-
https://huggingface.co/spaces/boka773/ConstructionProcectmanagement/resolve/main/components/MasterControl.tsx
- Command line
-
hf download hf://spaces/boka773/ConstructionProcectmanagement/components/MasterControl.tsx
-
curl -L -o MasterControl.tsx https://huggingface.co/spaces/boka773/ConstructionProcectmanagement/resolve/main/components/MasterControl.tsx
55.2 kB
| import React, { useState, useMemo, useEffect } from 'react'; | |
| import { ProjectState, ProjectDocument, UserRole, BOQItem, Unit, Priority } from '../types'; | |
| import DocumentManager from './DocumentManager'; | |
| import ChangeOrderManager from './ChangeOrderManager'; | |
| import ManualOverrideToggle from './ManualOverrideToggle'; | |
| import { | |
| PlusCircle, | |
| X, | |
| Search, | |
| Filter, | |
| ArrowUpDown, | |
| ChevronDown, | |
| ArrowUp, | |
| ArrowDown, | |
| Activity, | |
| RotateCcw, | |
| Sparkles, | |
| Loader2, | |
| ChevronUp, | |
| Layers, | |
| Flag, | |
| Save, | |
| Info, | |
| CheckCircle2, | |
| FileText, | |
| UploadCloud, | |
| Link as LinkIcon, | |
| Download, | |
| FileUp | |
| } from 'lucide-react'; | |
| import { suggestPlannedUnitCost, parseBOQDocument } from '../services/localAnalysisService'; | |
| interface MasterControlProps { | |
| data: ProjectState; | |
| onAddDocument: (doc: ProjectDocument) => void; | |
| onAddBOQItem: (item: BOQItem) => void; | |
| onUpdateBOQItem?: (itemId: string, updatedItem: Partial<BOQItem>) => void; | |
| onImportBOQItems: (items: BOQItem[]) => void; | |
| userRole: UserRole; | |
| } | |
| type SortField = 'id' | 'rate' | 'plannedUnitCost' | 'plannedQty' | 'executedQty' | 'progress' | 'revenue' | 'variance' | 'profit' | 'priority'; | |
| type SortDirection = 'asc' | 'desc'; | |
| type StatusFilter = 'ALL' | 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; | |
| const MasterControl: React.FC<MasterControlProps> = ({ data, onAddDocument, onAddBOQItem, onUpdateBOQItem, onImportBOQItems, userRole }) => { | |
| const [isModalOpen, setIsModalOpen] = useState(false); | |
| const [isImportModalOpen, setIsImportModalOpen] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [unitFilter, setUnitFilter] = useState<Unit | 'ALL'>('ALL'); | |
| const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL'); | |
| const [priorityFilter, setPriorityFilter] = useState<Priority | 'ALL'>('ALL'); | |
| const [sortField, setSortField] = useState<SortField>('id'); | |
| const [sortDirection, setSortDirection] = useState<SortDirection>('asc'); | |
| const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); | |
| // Import State | |
| const [importTab, setImportTab] = useState<'EXISTING' | 'UPLOAD'>('EXISTING'); | |
| const [selectedFileId, setSelectedFileId] = useState(''); | |
| const [fileToUpload, setFileToUpload] = useState<File | null>(null); | |
| const [isImporting, setIsImporting] = useState(false); | |
| const [importStatus, setImportStatus] = useState<string | null>(null); | |
| // Editing state for breakdown | |
| const [editId, setEditId] = useState<string | null>(null); | |
| const [editMat, setEditMat] = useState<string>('0'); | |
| const [editLab, setEditLab] = useState<string>('0'); | |
| const [editEqp, setEditEqp] = useState<string>('0'); | |
| const [editOH, setEditOH] = useState<string>('0'); | |
| const canEditBOQ = userRole === 'DIRECTOR' || userRole === 'MANAGER'; | |
| // Form State for Adding Item | |
| const [description, setDescription] = useState(''); | |
| const [unit, setUnit] = useState<Unit>(Unit.CUM); | |
| const [rate, setRate] = useState<string>(''); | |
| const [plannedUnitCost, setPlannedUnitCost] = useState<string>(''); | |
| const [itemPriority, setItemPriority] = useState<Priority>('MEDIUM'); | |
| const [plannedQty, setPlannedQty] = useState<string>(''); | |
| // Breakdown states for new item | |
| const [plannedMat, setPlannedMat] = useState<string>('0'); | |
| const [plannedLab, setPlannedLab] = useState<string>('0'); | |
| const [plannedEqp, setPlannedEqp] = useState<string>('0'); | |
| const [plannedOH, setPlannedOH] = useState<string>('0'); | |
| const [isSuggesting, setIsSuggesting] = useState(false); | |
| const [aiAppliedFields, setAiAppliedFields] = useState<boolean>(false); | |
| // Auto-calculate plannedUnitCost when breakdown changes for NEW item | |
| useEffect(() => { | |
| const total = Number(plannedMat) + Number(plannedLab) + Number(plannedEqp) + Number(plannedOH); | |
| setPlannedUnitCost(total.toString()); | |
| }, [plannedMat, plannedLab, plannedEqp, plannedOH]); | |
| // Use all documents for import selection to give user full flexibility | |
| const availableDocs = useMemo(() => { | |
| // Filter for documents that are likely to be BOQs (PDF, Excel, etc.) | |
| return data.documents.filter(d => | |
| ['PDF', 'XLSX', 'CSV', 'DOC', 'DOCX'].includes(d.type) || | |
| ['CONTRACT', 'REPORT', 'BILL'].includes(d.category) | |
| ); | |
| }, [data.documents]); | |
| const handleAddItem = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| const newItem: BOQItem = { | |
| id: `${(data.boq.length + 1) * 10}-NEW`, | |
| description, | |
| unit, | |
| rate: Number(rate), | |
| priority: itemPriority, | |
| plannedUnitCost: Number(plannedUnitCost), | |
| plannedBreakdown: { | |
| material: Number(plannedMat), | |
| labor: Number(plannedLab), | |
| equipment: Number(plannedEqp), | |
| overhead: Number(plannedOH) | |
| }, | |
| plannedQty: Number(plannedQty), | |
| executedQty: 0 | |
| }; | |
| onAddBOQItem(newItem); | |
| setIsModalOpen(false); | |
| resetForm(); | |
| }; | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files[0]) { | |
| setFileToUpload(e.target.files[0]); | |
| } | |
| }; | |
| const handleImport = async () => { | |
| if (importTab === 'EXISTING' && !selectedFileId) return; | |
| if (importTab === 'UPLOAD' && !fileToUpload) return; | |
| setIsImporting(true); | |
| try { | |
| let docName = ''; | |
| if (importTab === 'UPLOAD' && fileToUpload) { | |
| setImportStatus('Uploading & Scanning...'); | |
| // 1. Create and Add Document | |
| const newDoc: ProjectDocument = { | |
| id: `D${Date.now()}`, | |
| name: fileToUpload.name, | |
| type: fileToUpload.name.split('.').pop()?.toUpperCase() || 'PDF', | |
| category: 'CONTRACT', | |
| module: 'MASTER', | |
| uploadDate: new Date().toISOString().split('T')[0], | |
| size: `${(fileToUpload.size / (1024 * 1024)).toFixed(2)} MB`, | |
| url: URL.createObjectURL(fileToUpload), | |
| isAnalyzed: true | |
| }; | |
| onAddDocument(newDoc); | |
| docName = newDoc.name; | |
| // Artificial delay for UX | |
| await new Promise(resolve => setTimeout(resolve, 1500)); | |
| } else { | |
| const file = data.documents.find(d => d.id === selectedFileId); | |
| if (!file) return; | |
| docName = file.name; | |
| setImportStatus('Analyzing existing document...'); | |
| } | |
| // 2. Parse Items via AI Service | |
| const items = await parseBOQDocument(docName); | |
| setImportStatus(`Found ${items.length} BOQ items. Syncing...`); | |
| await new Promise(resolve => setTimeout(resolve, 1000)); | |
| // 3. Import Items | |
| onImportBOQItems(items); | |
| resetImport(); | |
| } catch (e) { | |
| setImportStatus('Failed to parse document.'); | |
| } finally { | |
| setIsImporting(false); | |
| } | |
| }; | |
| const resetImport = () => { | |
| setIsImportModalOpen(false); | |
| setSelectedFileId(''); | |
| setFileToUpload(null); | |
| setImportTab('EXISTING'); | |
| setImportStatus(null); | |
| }; | |
| const resetForm = () => { | |
| setDescription(''); | |
| setRate(''); | |
| setPlannedUnitCost(''); | |
| setPlannedQty(''); | |
| setPlannedMat('0'); | |
| setPlannedLab('0'); | |
| setPlannedEqp('0'); | |
| setPlannedOH('0'); | |
| setItemPriority('MEDIUM'); | |
| setAiAppliedFields(false); | |
| }; | |
| const handleSuggestCost = async () => { | |
| if (!description) return; | |
| setIsSuggesting(true); | |
| const suggestion = await suggestPlannedUnitCost(description, unit, data.boq); | |
| setIsSuggesting(false); | |
| if (suggestion) { | |
| setPlannedMat(suggestion.breakdown.material.toString()); | |
| setPlannedLab(suggestion.breakdown.labor.toString()); | |
| setPlannedEqp(suggestion.breakdown.equipment.toString()); | |
| setPlannedOH(suggestion.breakdown.overhead.toString()); | |
| setAiAppliedFields(true); | |
| // Reset highlight after 3 seconds | |
| setTimeout(() => setAiAppliedFields(false), 3000); | |
| } | |
| }; | |
| const startEditing = (item: BOQItem) => { | |
| setEditId(item.id); | |
| setEditMat(item.plannedBreakdown?.material?.toString() || '0'); | |
| setEditLab(item.plannedBreakdown?.labor?.toString() || '0'); | |
| setEditEqp(item.plannedBreakdown?.equipment?.toString() || '0'); | |
| setEditOH(item.plannedBreakdown?.overhead?.toString() || '0'); | |
| }; | |
| const cancelEditing = () => { | |
| setEditId(null); | |
| }; | |
| const saveEditing = (id: string) => { | |
| if (!onUpdateBOQItem) return; | |
| const total = Number(editMat) + Number(editLab) + Number(editEqp) + Number(editOH); | |
| onUpdateBOQItem(id, { | |
| plannedUnitCost: total, | |
| plannedBreakdown: { | |
| material: Number(editMat), | |
| labor: Number(editLab), | |
| equipment: Number(editEqp), | |
| overhead: Number(editOH) | |
| } | |
| }); | |
| setEditId(null); | |
| }; | |
| const handleLinkDocument = (itemId: string, docId: string) => { | |
| if (onUpdateBOQItem) { | |
| onUpdateBOQItem(itemId, { linkedDocId: docId || undefined }); | |
| } | |
| }; | |
| const toggleRow = (id: string) => { | |
| const newExpanded = new Set(expandedRows); | |
| if (newExpanded.has(id)) { | |
| newExpanded.delete(id); | |
| if (editId === id) setEditId(null); | |
| } else { | |
| newExpanded.add(id); | |
| } | |
| setExpandedRows(newExpanded); | |
| }; | |
| const handleSort = (field: SortField) => { | |
| if (sortField === field) { | |
| setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); | |
| } else { | |
| setSortField(field); | |
| setSortDirection('asc'); | |
| } | |
| }; | |
| const clearFilters = () => { | |
| setSearchTerm(''); | |
| setUnitFilter('ALL'); | |
| setStatusFilter('ALL'); | |
| setPriorityFilter('ALL'); | |
| }; | |
| const hasActiveFilters = searchTerm !== '' || unitFilter !== 'ALL' || statusFilter !== 'ALL' || priorityFilter !== 'ALL'; | |
| const getPriorityWeight = (p?: Priority) => { | |
| if (p === 'HIGH') return 3; | |
| if (p === 'MEDIUM') return 2; | |
| if (p === 'LOW') return 1; | |
| return 0; | |
| }; | |
| // Group documents by category for the dropdown | |
| const groupedDocuments = useMemo(() => { | |
| const groups: Record<string, ProjectDocument[]> = {}; | |
| data.documents.forEach(doc => { | |
| if (!groups[doc.category]) groups[doc.category] = []; | |
| groups[doc.category].push(doc); | |
| }); | |
| return groups; | |
| }, [data.documents]); | |
| const filteredAndSortedBOQ = useMemo(() => { | |
| return data.boq | |
| .filter(item => { | |
| const matchesSearch = item.description.toLowerCase().includes(searchTerm.toLowerCase()) || item.id.toLowerCase().includes(searchTerm.toLowerCase()); | |
| const matchesUnit = unitFilter === 'ALL' || item.unit === unitFilter; | |
| const matchesPriority = priorityFilter === 'ALL' || item.priority === priorityFilter; | |
| let currentStatus: StatusFilter = 'PENDING'; | |
| if (item.executedQty >= item.plannedQty) currentStatus = 'COMPLETED'; | |
| else if (item.executedQty > 0) currentStatus = 'IN_PROGRESS'; | |
| const matchesStatus = statusFilter === 'ALL' || currentStatus === statusFilter; | |
| return matchesSearch && matchesUnit && matchesStatus && matchesPriority; | |
| }) | |
| .sort((a, b) => { | |
| let valA: any, valB: any; | |
| const getVariance = (item: BOQItem) => { | |
| if (item.executedQty === 0 || !item.costAnalysis) return 0; | |
| return item.plannedUnitCost - item.costAnalysis.unitCost; | |
| }; | |
| const getProfit = (item: BOQItem) => { | |
| if (item.executedQty === 0) return 0; | |
| const cost = item.costAnalysis?.unitCost || item.plannedUnitCost; | |
| return (item.rate - cost) * item.executedQty; | |
| }; | |
| switch (sortField) { | |
| case 'rate': valA = a.rate; valB = b.rate; break; | |
| case 'plannedUnitCost': valA = a.plannedUnitCost; valB = b.plannedUnitCost; break; | |
| case 'plannedQty': valA = a.plannedQty; valB = b.plannedQty; break; | |
| case 'executedQty': valA = a.executedQty; valB = b.executedQty; break; | |
| case 'variance': valA = getVariance(a); valB = getVariance(b); break; | |
| case 'profit': valA = getProfit(a); valB = getProfit(b); break; | |
| case 'priority': valA = getPriorityWeight(a.priority); valB = getPriorityWeight(b.priority); break; | |
| case 'progress': | |
| valA = (a.executedQty / a.plannedQty) || 0; | |
| valB = (b.executedQty / b.plannedQty) || 0; | |
| break; | |
| case 'revenue': valA = a.rate * a.plannedQty; valB = b.rate * b.plannedQty; break; | |
| default: valA = a.id; valB = b.id; | |
| } | |
| if (valA < valB) return sortDirection === 'asc' ? -1 : 1; | |
| if (valA > valB) return sortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| }, [data.boq, searchTerm, unitFilter, statusFilter, priorityFilter, sortField, sortDirection]); | |
| const SortIcon = ({ field }: { field: SortField }) => { | |
| if (sortField !== field) return <ArrowUpDown className="w-3.5 h-3.5 ml-1 inline opacity-30 group-hover:opacity-100 transition-opacity" />; | |
| 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" />; | |
| }; | |
| const getPriorityColor = (p?: Priority) => { | |
| switch(p) { | |
| case 'HIGH': return 'bg-red-50 text-red-600 border-red-100'; | |
| case 'MEDIUM': return 'bg-amber-50 text-amber-600 border-amber-100'; | |
| case 'LOW': return 'bg-blue-50 text-blue-600 border-blue-100'; | |
| default: return 'bg-slate-50 text-slate-600 border-slate-100'; | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| <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">Master Control (Baseline)</h1> | |
| <p className="text-slate-500">Fixed Contract Data, BOQ & Budget</p> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm"> | |
| <h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Project Name</h4> | |
| <p className="text-lg font-medium text-slate-900 mt-1">{data.name}</p> | |
| </div> | |
| <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm"> | |
| <h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Contract Duration</h4> | |
| <p className="text-lg font-medium text-slate-900 mt-1">{data.startDate} to {data.endDate}</p> | |
| </div> | |
| <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm"> | |
| <h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Total Contract Value</h4> | |
| <p className="text-lg font-medium text-slate-900 mt-1">৳{data.contractValue.toLocaleString()}</p> | |
| </div> | |
| </div> | |
| <div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden"> | |
| {/* Table Toolbar */} | |
| <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"> | |
| <div className="flex items-center gap-3"> | |
| <h3 className="font-semibold text-slate-800">Bill of Quantities (BOQ)</h3> | |
| <span className="text-xs font-medium bg-white border border-slate-200 text-slate-500 px-2 py-1 rounded">Rev 1.0</span> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2 w-full xl:w-auto"> | |
| <div className="relative flex-1 min-w-[150px] xl:flex-none"> | |
| <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> | |
| <input | |
| type="text" | |
| placeholder="Search items..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| 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" | |
| /> | |
| </div> | |
| <div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-slate-300 rounded-lg"> | |
| <Filter className="w-3.5 h-3.5 text-slate-400" /> | |
| <select | |
| value={unitFilter} | |
| onChange={(e) => setUnitFilter(e.target.value as any)} | |
| className="text-xs font-medium text-slate-600 outline-none bg-transparent" | |
| > | |
| <option value="ALL">All Units</option> | |
| {Object.values(Unit).map(u => <option key={u} value={u}>{u}</option>)} | |
| </select> | |
| </div> | |
| <div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-white border border-slate-300 rounded-lg"> | |
| <Flag className="w-3.5 h-3.5 text-slate-400" /> | |
| <select | |
| value={priorityFilter} | |
| onChange={(e) => setPriorityFilter(e.target.value as any)} | |
| className="text-xs font-medium text-slate-600 outline-none bg-transparent" | |
| > | |
| <option value="ALL">All Priority</option> | |
| <option value="LOW">Low</option> | |
| <option value="MEDIUM">Medium</option> | |
| <option value="HIGH">High</option> | |
| </select> | |
| </div> | |
| {hasActiveFilters && ( | |
| <button | |
| onClick={clearFilters} | |
| 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" | |
| > | |
| <RotateCcw className="w-3.5 h-3.5" /> | |
| Clear | |
| </button> | |
| )} | |
| <div className="h-6 w-px bg-slate-300 mx-1 hidden xl:block"></div> | |
| {canEditBOQ && ( | |
| <> | |
| <ManualOverrideToggle /> | |
| <button | |
| onClick={() => setIsImportModalOpen(true)} | |
| 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" | |
| > | |
| <UploadCloud className="w-4 h-4" /> | |
| Import BOQ | |
| </button> | |
| <button | |
| onClick={() => setIsModalOpen(true)} | |
| 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" | |
| > | |
| <PlusCircle className="w-4 h-4" /> | |
| Add Item | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-left text-sm border-collapse"> | |
| <thead className="bg-white text-slate-600 font-medium border-b border-slate-200"> | |
| <tr> | |
| <th className="px-6 py-4 w-8"></th> | |
| <th className="px-6 py-4 cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('id')}> | |
| ID <SortIcon field="id" /> | |
| </th> | |
| <th className="px-6 py-4">Description & Status</th> | |
| <th className="px-6 py-4 cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('priority')}> | |
| Priority <SortIcon field="priority" /> | |
| </th> | |
| <th className="px-6 py-4">Unit</th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('rate')}> | |
| Rate (৳) <SortIcon field="rate" /> | |
| </th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('variance')}> | |
| Cost Var. <SortIcon field="variance" /> | |
| </th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('profit')}> | |
| Profit Cont. <SortIcon field="profit" /> | |
| </th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('plannedQty')}> | |
| Planned <SortIcon field="plannedQty" /> | |
| </th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('executedQty')}> | |
| Executed <SortIcon field="executedQty" /> | |
| </th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('progress')}> | |
| Progress <SortIcon field="progress" /> | |
| </th> | |
| <th className="px-6 py-4 text-right cursor-pointer hover:bg-slate-50 transition-colors group" onClick={() => handleSort('revenue')}> | |
| Total Rev (৳) <SortIcon field="revenue" /> | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-slate-100"> | |
| {filteredAndSortedBOQ.map((item) => { | |
| const progress = Math.min(100, (item.executedQty / item.plannedQty) * 100) || 0; | |
| const isCompleted = item.executedQty >= item.plannedQty; | |
| const isInProgress = item.executedQty > 0 && !isCompleted; | |
| const isExpanded = expandedRows.has(item.id); | |
| const isEditing = editId === item.id; | |
| // Finance Analysis | |
| const hasActualCost = item.executedQty > 0 && !!item.costAnalysis; | |
| const actualCost = item.costAnalysis?.unitCost || 0; | |
| const costVariance = hasActualCost ? (item.plannedUnitCost - actualCost) : 0; | |
| const margin = hasActualCost ? (item.rate - actualCost) : (item.rate - item.plannedUnitCost); | |
| const profitContribution = item.executedQty > 0 ? (margin * item.executedQty) : 0; | |
| const currentTotal = isEditing ? (Number(editMat) + Number(editLab) + Number(editEqp) + Number(editOH)) : item.plannedUnitCost; | |
| return ( | |
| <React.Fragment key={item.id}> | |
| <tr | |
| className={`hover:bg-slate-50/50 transition-colors group cursor-pointer ${isExpanded ? 'bg-slate-50/80' : ''}`} | |
| onClick={() => toggleRow(item.id)} | |
| > | |
| <td className="px-4 py-4 text-slate-400"> | |
| {isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} | |
| </td> | |
| <td className="px-6 py-4 font-medium text-slate-900 whitespace-nowrap">{item.id}</td> | |
| <td className="px-6 py-4 text-slate-700 min-w-[280px]"> | |
| <div className="font-medium text-slate-800 line-clamp-1 group-hover:line-clamp-none transition-all duration-300"> | |
| {item.description} | |
| </div> | |
| <div className="mt-1.5 flex items-center gap-2"> | |
| {isCompleted ? ( | |
| <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> | |
| ) : isInProgress ? ( | |
| <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> | |
| ) : ( | |
| <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> | |
| )} | |
| <span className="text-[10px] text-slate-400 font-medium">Budget: ৳{item.plannedUnitCost.toLocaleString()}</span> | |
| {/* Linked Doc Indicator */} | |
| {item.linkedDocId && ( | |
| <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"> | |
| <LinkIcon className="w-3 h-3" /> | |
| Doc | |
| </span> | |
| )} | |
| </div> | |
| </td> | |
| <td className="px-6 py-4"> | |
| <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)}`}> | |
| <Flag className="w-2.5 h-2.5" /> | |
| {item.priority || 'N/A'} | |
| </div> | |
| </td> | |
| <td className="px-6 py-4"> | |
| <span className="text-xs font-bold bg-slate-100 px-2 py-0.5 rounded text-slate-500"> | |
| {item.unit} | |
| </span> | |
| </td> | |
| <td className="px-6 py-4 text-right text-slate-700 font-mono"> | |
| {item.rate.toLocaleString(undefined, { minimumFractionDigits: 2 })} | |
| </td> | |
| <td className="px-6 py-4 text-right"> | |
| {hasActualCost ? ( | |
| <div className="flex flex-col items-end"> | |
| <span className={`font-mono text-xs font-bold ${costVariance >= 0 ? 'text-emerald-600' : 'text-red-600'}`}> | |
| {costVariance >= 0 ? '+' : ''}{costVariance.toFixed(2)} | |
| </span> | |
| </div> | |
| ) : ( | |
| <span className="text-slate-300 font-mono text-xs italic">N/A</span> | |
| )} | |
| </td> | |
| <td className="px-6 py-4 text-right"> | |
| {item.executedQty > 0 ? ( | |
| <div className="flex flex-col items-end"> | |
| <span className={`font-mono text-xs font-bold ${profitContribution >= 0 ? 'text-blue-700' : 'text-red-700'}`}> | |
| ৳{profitContribution.toLocaleString(undefined, { maximumFractionDigits: 0 })} | |
| </span> | |
| </div> | |
| ) : ( | |
| <span className="text-slate-300 font-mono text-xs">0</span> | |
| )} | |
| </td> | |
| <td className="px-6 py-4 text-right text-slate-600 font-mono"> | |
| {item.plannedQty.toLocaleString()} | |
| </td> | |
| <td className="px-6 py-4 text-right text-slate-900 font-semibold font-mono"> | |
| {item.executedQty.toLocaleString()} | |
| </td> | |
| <td className="px-6 py-4 text-right"> | |
| <div className="flex flex-col items-end gap-1"> | |
| <span className={`text-[11px] font-bold ${progress >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-600' : 'text-slate-400'}`}> | |
| {progress.toFixed(1)}% | |
| </span> | |
| <div className="w-16 h-1 bg-slate-100 rounded-full overflow-hidden"> | |
| <div | |
| className={`h-full transition-all duration-500 ${progress >= 100 ? 'bg-emerald-500' : 'bg-blue-500'}`} | |
| style={{ width: `${progress}%` }} | |
| ></div> | |
| </div> | |
| </div> | |
| </td> | |
| <td className="px-6 py-4 text-right font-bold text-slate-900 font-mono"> | |
| {(item.rate * item.plannedQty).toLocaleString(undefined, { minimumFractionDigits: 2 })} | |
| </td> | |
| </tr> | |
| {isExpanded && ( | |
| <tr className="bg-slate-50 border-y border-slate-200" onClick={(e) => e.stopPropagation()}> | |
| <td colSpan={12} className="px-6 py-6"> | |
| <div className="max-w-4xl bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden ml-8"> | |
| <div className="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <Layers className="w-4 h-4 text-indigo-500" /> | |
| <span className="text-xs font-bold text-slate-600 uppercase tracking-wider">Item Details & Cost Breakdown</span> | |
| </div> | |
| {!isEditing && canEditBOQ && ( | |
| <button | |
| onClick={() => startEditing(item)} | |
| 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" | |
| > | |
| Edit Components | |
| </button> | |
| )} | |
| {isEditing && ( | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={cancelEditing} | |
| className="text-xs font-bold text-slate-500 hover:text-slate-700 px-3 py-1 rounded-lg transition-colors" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={() => saveEditing(item.id)} | |
| 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" | |
| > | |
| <Save className="w-3 h-3" /> | |
| Save Changes | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="p-6"> | |
| {/* Document Link Section */} | |
| <div className="mb-6 bg-slate-50 p-3 rounded-lg border border-slate-200 flex items-center gap-3"> | |
| <div className="p-2 bg-white rounded-md border border-slate-200 text-slate-400"> | |
| <LinkIcon className="w-4 h-4" /> | |
| </div> | |
| <div className="flex-1"> | |
| <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Linked Reference Document</label> | |
| <select | |
| value={item.linkedDocId || ''} | |
| onChange={(e) => handleLinkDocument(item.id, e.target.value)} | |
| disabled={!canEditBOQ} | |
| 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" | |
| > | |
| <option value="">Select a document to link (Contract, Bill, Drawing)...</option> | |
| {Object.entries(groupedDocuments).map(([category, docs]) => ( | |
| <optgroup key={category} label={category}> | |
| {docs.map(d => ( | |
| <option key={d.id} value={d.id}> | |
| {d.name} ({d.uploadDate}) | |
| </option> | |
| ))} | |
| </optgroup> | |
| ))} | |
| {Object.keys(groupedDocuments).length === 0 && ( | |
| <option disabled value="">No documents available</option> | |
| )} | |
| </select> | |
| </div> | |
| {item.linkedDocId && ( | |
| <a | |
| href={data.documents.find(d => d.id === item.linkedDocId)?.url} | |
| download={data.documents.find(d => d.id === item.linkedDocId)?.name} | |
| className="text-blue-600 hover:text-blue-800 p-2 hover:bg-blue-50 rounded-full transition-colors" | |
| title="Download Linked Document" | |
| > | |
| <Download className="w-4 h-4" /> | |
| </a> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-4 gap-8 mb-8"> | |
| {/* Component Inputs/Views */} | |
| {[ | |
| { label: 'Material', value: item.plannedBreakdown?.material || 0, editValue: editMat, setEdit: setEditMat, color: 'bg-blue-500' }, | |
| { label: 'Labor', value: item.plannedBreakdown?.labor || 0, editValue: editLab, setEdit: setEditLab, color: 'bg-amber-500' }, | |
| { label: 'Equipment', value: item.plannedBreakdown?.equipment || 0, editValue: editEqp, setEdit: setEditEqp, color: 'bg-emerald-500' }, | |
| { label: 'Overhead', value: item.plannedBreakdown?.overhead || 0, editValue: editOH, setEdit: setEditOH, color: 'bg-violet-500' }, | |
| ].map((comp, idx) => { | |
| const percent = (isEditing ? Number(comp.editValue) : comp.value) / (currentTotal || 1) * 100 || 0; | |
| return ( | |
| <div key={idx} className="space-y-3"> | |
| <div className="flex justify-between items-center"> | |
| <p className="text-[11px] font-bold text-slate-400 uppercase tracking-wide">{comp.label}</p> | |
| <span className="text-[10px] font-bold text-slate-400">{percent.toFixed(0)}%</span> | |
| </div> | |
| {isEditing ? ( | |
| <div className="relative"> | |
| <span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs font-mono">৳</span> | |
| <input | |
| type="number" | |
| value={comp.editValue} | |
| onChange={(e) => comp.setEdit(e.target.value)} | |
| 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" | |
| /> | |
| </div> | |
| ) : ( | |
| <p className="text-xl font-mono font-bold text-slate-800">৳{comp.value.toLocaleString()}</p> | |
| )} | |
| <div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden shadow-inner"> | |
| <div className={`h-full transition-all duration-500 ${comp.color}`} style={{ width: `${percent}%` }}></div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-200"> | |
| <div className="flex items-center gap-2"> | |
| <Info className="w-4 h-4 text-blue-500" /> | |
| <span className="text-sm font-semibold text-slate-600">Calculated Planned Unit Cost:</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {isEditing && currentTotal !== item.plannedUnitCost && ( | |
| <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'}`}> | |
| {currentTotal > item.plannedUnitCost ? 'Increased' : 'Decreased'} by ৳{Math.abs(currentTotal - item.plannedUnitCost).toLocaleString()} | |
| </span> | |
| )} | |
| <span className="text-2xl font-mono font-black text-indigo-700"> | |
| ৳{currentTotal.toLocaleString(undefined, { minimumFractionDigits: 2 })} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </td> | |
| </tr> | |
| )} | |
| </React.Fragment> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div className="pt-10 border-t border-slate-200"> | |
| <div className="flex items-center gap-3 mb-6"> | |
| <div className="p-2 bg-amber-100 text-amber-600 rounded-lg"> | |
| <Activity className="w-6 h-6" /> | |
| </div> | |
| <div> | |
| <h2 className="text-xl font-bold text-slate-800">Change Order Management</h2> | |
| <p className="text-sm text-slate-500">Track extra work requests and contract variations</p> | |
| </div> | |
| </div> | |
| <ChangeOrderManager changeOrders={data.changeOrders || []} /> | |
| </div> | |
| {/* Import BOQ Modal */} | |
| {isImportModalOpen && ( | |
| <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-2xl w-full max-w-md overflow-hidden"> | |
| <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 text-indigo-800"> | |
| <UploadCloud className="w-5 h-5" /> | |
| <h3 className="font-bold">Import BOQ Items</h3> | |
| </div> | |
| <button onClick={resetImport} className="text-slate-400 hover:text-slate-600"> | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Tabs */} | |
| <div className="flex border-b border-slate-200"> | |
| <button | |
| 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'}`} | |
| onClick={() => setImportTab('EXISTING')} | |
| > | |
| Select Existing | |
| </button> | |
| <button | |
| 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'}`} | |
| onClick={() => setImportTab('UPLOAD')} | |
| > | |
| Upload New | |
| </button> | |
| </div> | |
| <div className="p-6 space-y-4"> | |
| <p className="text-sm text-slate-600"> | |
| AI will extract items, quantities, and rates from the document to populate the master schedule. | |
| </p> | |
| {importTab === 'EXISTING' ? ( | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight mb-2">Select Existing Document</label> | |
| <div className="relative"> | |
| <FileText className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> | |
| <select | |
| value={selectedFileId} | |
| onChange={(e) => setSelectedFileId(e.target.value)} | |
| 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" | |
| > | |
| <option value="">-- Choose BOQ File --</option> | |
| {availableDocs.map(f => ( | |
| <option key={f.id} value={f.id}>{f.name} ({f.category})</option> | |
| ))} | |
| </select> | |
| </div> | |
| {availableDocs.length === 0 && ( | |
| <p className="text-xs text-red-500 mt-1">No compatible files found (PDF/Excel).</p> | |
| )} | |
| </div> | |
| ) : ( | |
| <div> | |
| <label className="block text-xs font-bold text-slate-500 uppercase tracking-tight mb-2">Upload File</label> | |
| <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'}`}> | |
| <input | |
| type="file" | |
| onChange={handleFileChange} | |
| accept=".pdf,.xlsx,.csv,.doc,.docx" | |
| disabled={isImporting} | |
| className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" | |
| /> | |
| <div className="flex flex-col items-center gap-2"> | |
| <div className={`p-3 rounded-full ${fileToUpload ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-100 text-slate-500'}`}> | |
| {fileToUpload ? <FileUp className="w-6 h-6" /> : <UploadCloud className="w-6 h-6" />} | |
| </div> | |
| <p className="text-sm font-medium text-slate-700 max-w-[200px] truncate"> | |
| {fileToUpload ? fileToUpload.name : "Click to browse or drag file"} | |
| </p> | |
| {!fileToUpload && <p className="text-xs text-slate-400">PDF, Excel, or Word</p>} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {importStatus && ( | |
| <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"> | |
| {isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle2 className="w-4 h-4" />} | |
| {importStatus} | |
| </div> | |
| )} | |
| <div className="pt-2 flex justify-end gap-2"> | |
| <button | |
| onClick={resetImport} | |
| className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50 rounded-lg" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={handleImport} | |
| disabled={(importTab === 'EXISTING' && !selectedFileId) || (importTab === 'UPLOAD' && !fileToUpload) || isImporting} | |
| 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" | |
| > | |
| {isImporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />} | |
| {isImporting ? 'Processing...' : 'Sync & Parse'} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Add Item Modal */} | |
| {isModalOpen && ( | |
| <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-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200"> | |
| <div className="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50/50"> | |
| <div className="flex items-center gap-2"> | |
| <h3 className="font-semibold text-slate-800">Add New BOQ Item</h3> | |
| {isSuggesting && ( | |
| <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"> | |
| <Sparkles className="w-2.5 h-2.5" /> | |
| AI Estimating... | |
| </div> | |
| )} | |
| </div> | |
| <button onClick={() => { setIsModalOpen(false); resetForm(); }} className="text-slate-400 hover:text-slate-600 transition-colors"> | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <form onSubmit={handleAddItem} className="p-6 space-y-4 max-h-[80vh] overflow-y-auto"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Item Description</label> | |
| <textarea | |
| required | |
| rows={2} | |
| value={description} | |
| onChange={(e) => setDescription(e.target.value)} | |
| 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" | |
| placeholder="e.g., Earth work in cutting and filling of eroded bank" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Unit</label> | |
| <select | |
| value={unit} | |
| onChange={(e) => setUnit(e.target.value as Unit)} | |
| 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" | |
| > | |
| {Object.values(Unit).map(u => <option key={u} value={u}>{u}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Priority</label> | |
| <select | |
| value={itemPriority} | |
| onChange={(e) => setItemPriority(e.target.value as Priority)} | |
| 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" | |
| > | |
| <option value="LOW">Low</option> | |
| <option value="MEDIUM">Medium</option> | |
| <option value="HIGH">High</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Selling Rate (৳)</label> | |
| <input | |
| type="number" | |
| min="0" | |
| step="0.01" | |
| required | |
| value={rate} | |
| onChange={(e) => setRate(e.target.value)} | |
| 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" | |
| placeholder="0.00" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Planned Quantity</label> | |
| <input | |
| type="number" | |
| min="0" | |
| step="0.01" | |
| required | |
| value={plannedQty} | |
| onChange={(e) => setPlannedQty(e.target.value)} | |
| 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" | |
| placeholder="0.00" | |
| /> | |
| </div> | |
| </div> | |
| <div className="bg-slate-50 rounded-xl p-4 border border-slate-200 space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex flex-col"> | |
| <h4 className="text-xs font-bold text-slate-500 uppercase">Internal Budget Components</h4> | |
| {aiAppliedFields && ( | |
| <span className="text-[10px] text-indigo-600 font-bold flex items-center gap-1 animate-in fade-in slide-in-from-left-2"> | |
| <CheckCircle2 className="w-3 h-3" /> Populated by AI | |
| </span> | |
| )} | |
| </div> | |
| {description && ( | |
| <button | |
| type="button" | |
| onClick={handleSuggestCost} | |
| disabled={isSuggesting} | |
| 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" | |
| > | |
| {isSuggesting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />} | |
| AI Suggest Cost | |
| </button> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Material</label> | |
| <input | |
| type="number" | |
| value={plannedMat} | |
| onChange={(e) => { setPlannedMat(e.target.value); setAiAppliedFields(false); }} | |
| 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'}`} | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Labor</label> | |
| <input | |
| type="number" | |
| value={plannedLab} | |
| onChange={(e) => { setPlannedLab(e.target.value); setAiAppliedFields(false); }} | |
| 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'}`} | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Equipment</label> | |
| <input | |
| type="number" | |
| value={plannedEqp} | |
| onChange={(e) => { setPlannedEqp(e.target.value); setAiAppliedFields(false); }} | |
| 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'}`} | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-[11px] font-medium text-slate-500 mb-1 uppercase">Overhead</label> | |
| <input | |
| type="number" | |
| value={plannedOH} | |
| onChange={(e) => { setPlannedOH(e.target.value); setAiAppliedFields(false); }} | |
| 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'}`} | |
| /> | |
| </div> | |
| </div> | |
| <div className="pt-2 border-t border-slate-200"> | |
| <div className="flex justify-between items-center"> | |
| <span className="text-sm font-bold text-slate-700">Planned Unit Cost:</span> | |
| <span className="text-xl font-mono font-black text-indigo-700">৳{Number(plannedUnitCost).toLocaleString()}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="pt-4 flex justify-end gap-3 border-t border-slate-100"> | |
| <button | |
| type="button" | |
| onClick={() => { setIsModalOpen(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-all shadow-md active:scale-95" | |
| > | |
| Create BOQ Item | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default MasterControl; | |