diff --git a/src/App.jsx b/src/App.jsx index b86c217..a1e9a5a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,6 +23,7 @@ import PlaceholderDashboard from './pages/PlaceholderDashboard'; import ProCanvas from './pages/ProCanvas'; import ProjectList from './pages/contractor/ProjectList'; import AiAssistantPage from './pages/AiAssistantPage'; +import EstimateBuilder from './pages/EstimateBuilder'; // New Dashboards import VendorDashboard from './pages/vendor/VendorDashboard'; import VendorOrders from './pages/vendor/VendorOrders'; @@ -97,6 +98,14 @@ function App() { } /> + + + + } + /> {/* Owner Routes */} @@ -138,6 +147,11 @@ function App() { } /> + + + + } /> {/* Protected Admin Routes — Owner has full admin access */} } /> + + + + } + /> {/* Contractor Routes */} { icon: LayoutDashboard, // Placeholder icon, maybe change later label: ProCanvas }, + { to: "/owner/estimate", icon: Calculator, label: "Estimate Builder" }, { to: "/admin/schedule", icon: Calendar, label: "Team Schedule" }, { to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" }, ...commonItems, @@ -159,6 +160,7 @@ const Layout = () => { icon: LayoutDashboard, label: ProCanvas }, + { to: "/admin/estimate", icon: Calculator, label: "Estimate Builder" }, ...commonItems, ]; case 'CONTRACTOR': @@ -183,6 +185,7 @@ const Layout = () => { icon: LayoutDashboard, label: ProCanvas }, + { to: "/emp/fa/estimate", icon: Calculator, label: "Estimate Builder" }, ...commonItems, ]; default: // Customer or Fallback diff --git a/src/pages/EstimateBuilder.jsx b/src/pages/EstimateBuilder.jsx new file mode 100644 index 0000000..d8fea34 --- /dev/null +++ b/src/pages/EstimateBuilder.jsx @@ -0,0 +1,681 @@ +import React, { useState, useEffect } from 'react'; +import { + Plus, Trash2, Calculator, Settings, AlertCircle, Save, ArrowLeft, + FileText, User, MapPin, Phone, Mail, CheckCircle2, MoreHorizontal, ChevronDown, DollarSign, Maximize, Minimize +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +const generateId = () => Math.random().toString(36).substr(2, 9); + +const UOM_OPTIONS = ['SQ', 'EA', 'BD', 'RL', 'LF', 'HR', 'LS']; + +export default function EstimateBuilder() { + const navigate = useNavigate(); + + // --- Zone A: Client Info --- + const [clientInfo, setClientInfo] = useState({ + name: '', + phone: '', + email: '', + address: '', + zip: '' + }); + + // Basic Validation states + const [errors, setErrors] = useState({}); + + const validateEmail = (email) => { + if (!email) return true; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }; + + const validatePhone = (phone) => { + if (!phone) return true; + return phone.length >= 10; + }; + + const handleClientChange = (e) => { + const { name, value } = e.target; + setClientInfo(prev => ({ ...prev, [name]: value })); + + // Clear error on change + if (name === 'email' && validateEmail(value)) { + setErrors(prev => ({ ...prev, email: null })); + } else if (name === 'email') { + setErrors(prev => ({ ...prev, email: 'Invalid email address' })); + } + + if (name === 'phone' && validatePhone(value)) { + setErrors(prev => ({ ...prev, phone: null })); + } else if (name === 'phone' && value.length > 0) { + setErrors(prev => ({ ...prev, phone: 'Invalid phone (min 10 chars)' })); + } + }; + + // --- Zone B: Scope of Work --- + const [scopeOfWork, setScopeOfWork] = useState(''); + + // --- Zone C: Costing Engine --- + const [sections, setSections] = useState([ + { + id: generateId(), + name: 'Roofing Section', + groups: [ + { + id: generateId(), + name: 'Materials', + items: [ + { id: generateId(), desc: 'CertainTeed Landmark AR', qty: 98.41, uom: 'SQ', unitCost: 42.09, clientPrice: 19182.12 } + ] + }, + { + id: generateId(), + name: 'Labor', + items: [ + { id: generateId(), desc: 'Tear off and Install Laminated Shingles', qty: 98.41, uom: 'SQ', unitCost: 80.00, clientPrice: 12121.46 } + ] + } + ] + } + ]); + + // --- Zone D: Financials --- + const [financials, setFinancials] = useState({ + taxRate: 0, + ohp: 0, + }); + + const [isFullViewMode, setIsFullViewMode] = useState(false); + + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape' && isFullViewMode) { + setIsFullViewMode(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isFullViewMode]); + + // Handlers for Zone C + const addSection = () => { + setSections([...sections, { + id: generateId(), + name: `New Section ${sections.length + 1}`, + groups: [] + }]); + }; + + const addGroup = (sectionId) => { + setSections(sections.map(s => { + if (s.id === sectionId) { + return { + ...s, + groups: [...s.groups, { id: generateId(), name: `New Group`, items: [] }] + }; + } + return s; + })); + }; + + const addItem = (sectionId, groupId) => { + setSections(sections.map(s => { + if (s.id === sectionId) { + return { + ...s, + groups: s.groups.map(g => { + if (g.id === groupId) { + return { + ...g, + items: [...g.items, { id: generateId(), desc: '', qty: 0, uom: 'EA', unitCost: 0, clientPrice: 0 }] + }; + } + return g; + }) + }; + } + return s; + })); + }; + + const updateItem = (sectionId, groupId, itemId, field, value) => { + setSections(sections.map(s => { + if (s.id === sectionId) { + return { + ...s, + groups: s.groups.map(g => { + if (g.id === groupId) { + return { + ...g, + items: g.items.map(i => { + if (i.id === itemId) { + const parsedVal = (field === 'qty' || field === 'unitCost' || field === 'clientPrice') ? parseFloat(value) || 0 : value; + return { ...i, [field]: parsedVal }; + } + return i; + }) + }; + } + return g; + }) + }; + } + return s; + })); + }; + + const removeItem = (sectionId, groupId, itemId) => { + setSections(sections.map(s => { + if (s.id === sectionId) { + return { + ...s, + groups: s.groups.map(g => { + if (g.id === groupId) { + return { ...g, items: g.items.filter(i => i.id !== itemId) }; + } + return g; + }) + }; + } + return s; + })); + }; + + const updateSectionName = (sectionId, name) => { + setSections(sections.map(s => s.id === sectionId ? { ...s, name } : s)); + }; + + const updateGroupName = (sectionId, groupId, name) => { + setSections(sections.map(s => { + if (s.id === sectionId) { + return { ...s, groups: s.groups.map(g => g.id === groupId ? { ...g, name } : g) }; + } + return s; + })); + }; + + // Calculations + const calculateTotals = () => { + let internalCost = 0; + let clientRevenue = 0; + + sections.forEach(s => { + s.groups.forEach(g => { + g.items.forEach(i => { + internalCost += (i.qty * i.unitCost); + clientRevenue += i.clientPrice; + }); + }); + }); + + const subtotal = clientRevenue; + const tax = subtotal * (financials.taxRate / 100); + const ohpAmount = subtotal * (financials.ohp / 100); + const grandTotal = subtotal + tax + ohpAmount; + + // Profit margin calculation (based on Revenue - Cost) + // Here we consider total revenue as the Subtotal + OHP. + // If OHP is part of revenue, Final Gross Margin % = ((GrandTotal(excluding tax) - InternalCost) / GrandTotal(excluding tax)) * 100 + const totalRevenueNoTax = subtotal + ohpAmount; + const netProfit = totalRevenueNoTax - internalCost; + const grossMarginPercent = totalRevenueNoTax > 0 ? (netProfit / totalRevenueNoTax) * 100 : 0; + + return { internalCost, clientRevenue, subtotal, tax, ohpAmount, grandTotal, netProfit, grossMarginPercent }; + }; + + const totals = calculateTotals(); + + // Helper calculation for a particular group + const calcGroupTotals = (group) => { + let cost = 0; + let rev = 0; + group.items.forEach(i => { + cost += (i.qty * i.unitCost); + rev += i.clientPrice; + }); + return { cost, rev }; + }; + + return ( +
+ + {/* --- Page Header --- */} +
+
+ +

Estimate Builder

+

Create and configure project estimates for clients.

+
+
+ + +
+
+ +
+ + {/* === MAIN CONTENT (Left Col - 8) === */} +
+ + {/* --- Zone A: Client & Project Metadata --- */} +
+
+
+
+ +
+

Client Details

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+ {errors.phone &&

{errors.phone}

} +
+ +
+ +
+ + +
+ {errors.email &&

{errors.email}

} +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+
+
+ + {/* --- Zone B: Scope of Work --- */} +
+
+
+ +
+

Scope of Work

+
+