import React, { useState, useEffect, useRef } from 'react'; import { Ruler, Plus, Trash2, Bluetooth, BluetoothConnected, FileText, Settings, Package, Calculator, Download, Layers, Droplets, Square, Layout, PlusCircle, Pencil, X, Copy, Check, ShieldCheck, Box, Tag, Info, User, MapPin, Hash, Receipt, Droplet, Edit3, MessageSquare, Phone, Printer } from 'lucide-react'; const App = () => { // --- STATE MANAGEMENT --- const [activeTab, setActiveTab] = useState('measure'); const [areas, setAreas] = useState([]); const [extras, setExtras] = useState([]); const [editingId, setEditingId] = useState(null); // Client & Project Details const [projectInfo, setProjectInfo] = useState({ customerName: '', phoneNumber: '', siteAddress: '', quoteReference: `QT-${Math.floor(1000 + Math.random() * 9000)}`, date: new Date().toLocaleDateString('en-GB') }); // VAT Settings const [isVatRegistered, setIsVatRegistered] = useState(false); const VAT_RATE = 0.20; // New Area Form State const [currentArea, setCurrentArea] = useState({ name: '', type: 'floor', isManualArea: false, manualArea: '', width: '', length: '', tilePrice: '', wastePercent: 10, hasCementBoard: false, hasAntiCrack: false, hasTanking: false }); // Extras Form State const [currentExtra, setCurrentExtra] = useState({ name: '', price: '', qty: 1 }); // Bluetooth State const [btStatus, setBtStatus] = useState('disconnected'); const [lastBleValue, setLastBleValue] = useState(null); const bleDevice = useRef(null); // Pricing Defaults const [laborRate, setLaborRate] = useState(35); const [adhesivePrice, setAdhesivePrice] = useState(18.50); const [adhesiveCoverage, setAdhesiveCoverage] = useState(4.5); const [groutPrice, setGroutPrice] = useState(12.00); const [groutCoverage, setGroutCoverage] = useState(12.0); const [primerPrice, setPrimerPrice] = useState(15.00); const [primerCoverage, setPrimerCoverage] = useState(25); const [sealantPrice, setSealantPrice] = useState(8.50); const [sealantCoverage, setSealantCoverage] = useState(8); const [cementBoardPrice, setCementBoardPrice] = useState(15.00); const [antiCrackPrice, setAntiCrackPrice] = useState(12.50); const [tankingPrice, setTankingPrice] = useState(18.00); // --- CALCULATIONS --- const grandTotalNetSqM = areas.reduce((acc, a) => acc + parseFloat(a.sqM || 0), 0); const grandTotalGrossSqM = areas.reduce((acc, a) => acc + parseFloat(a.totalSqM || 0), 0); const totalTileCost = areas.reduce((acc, a) => acc + (a.tileTotal || 0), 0); const totalPrepCost = areas.reduce((acc, a) => acc + (a.cementBoardCost || 0) + (a.antiCrackCost || 0) + (a.tankingCost || 0), 0); const bagsAdhesive = Math.ceil(grandTotalGrossSqM / adhesiveCoverage) || 0; const totalAdhesiveCost = bagsAdhesive * adhesivePrice; const bagsGrout = Math.ceil(grandTotalGrossSqM / groutCoverage) || 0; const totalGroutCost = bagsGrout * groutPrice; const tubsPrimer = Math.ceil(grandTotalGrossSqM / primerCoverage) || 0; const totalPrimerCost = tubsPrimer * primerPrice; const tubesSealant = Math.ceil(grandTotalGrossSqM / sealantCoverage) || 0; const totalSealantCost = tubesSealant * sealantPrice; const totalMaterialCost = totalTileCost + totalAdhesiveCost + totalGroutCost + totalPrepCost + totalPrimerCost + totalSealantCost; const totalLabor = grandTotalGrossSqM * laborRate; const totalExtras = extras.reduce((acc, e) => acc + (e.total || 0), 0); const subTotalNet = totalMaterialCost + totalLabor + totalExtras; const vatAmount = isVatRegistered ? subTotalNet * VAT_RATE : 0; const grandTotalGross = subTotalNet + vatAmount; // --- BLUETOOTH LOGIC --- const connectLaser = async () => { try { setBtStatus('connecting'); const device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: ['0000ffe0-0000-1000-8000-00805f9b34fb', '02a10001-524f-4b49-424f-5343484d5354'] }); const server = await device.gatt.connect(); const services = await server.getPrimaryServices(); const char = (await services[0].getCharacteristics()).find(c => c.properties.notify); if (char) { await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (e) => { const val = new TextDecoder().decode(e.target.value).trim().match(/[0-9]+\.[0-9]+/); if (val) { setLastBleValue(val[0]); setCurrentArea(p => { if (p.isManualArea) return {...p, manualArea: val[0]}; if (!p.width) return {...p, width: val[0]}; if (!p.length) return {...p, length: val[0]}; return p; }); } }); bleDevice.current = device; setBtStatus('connected'); device.addEventListener('gattserverdisconnected', () => setBtStatus('disconnected')); } } catch (err) { console.error(err); setBtStatus('disconnected'); } }; // --- AREA HANDLERS --- const saveArea = () => { const isManual = currentArea.isManualArea; if (!currentArea.name || (isManual ? !currentArea.manualArea : (!currentArea.width || !currentArea.length))) return; const sqM = isManual ? parseFloat(currentArea.manualArea) : parseFloat(currentArea.width) * parseFloat(currentArea.length); const waste = sqM * (currentArea.wastePercent / 100); const totalSqM = sqM + waste; const tileTotal = totalSqM * (parseFloat(currentArea.tilePrice) || 0); const areaData = { ...currentArea, sqM: sqM.toFixed(2), totalSqM: totalSqM.toFixed(2), tileTotal, cementBoardCost: currentArea.type === 'floor' && currentArea.hasCementBoard ? totalSqM * cementBoardPrice : 0, antiCrackCost: currentArea.type === 'floor' && currentArea.hasAntiCrack ? totalSqM * antiCrackPrice : 0, tankingCost: currentArea.type === 'wall' && currentArea.hasTanking ? totalSqM * tankingPrice : 0 }; if (editingId) { setAreas(areas.map(a => a.id === editingId ? { ...areaData, id: editingId } : a)); setEditingId(null); } else { setAreas([...areas, { ...areaData, id: Date.now() }]); } // Clear but keep defaults setCurrentArea({ ...currentArea, name: '', manualArea: '', width: '', length: '', hasCementBoard: false, hasAntiCrack: false, hasTanking: false, isManualArea: false }); }; const addExtra = () => { if (!currentExtra.name || !currentExtra.price) return; const p = parseFloat(currentExtra.price); const q = parseInt(currentExtra.qty) || 1; setExtras([...extras, { ...currentExtra, id: Date.now(), total: p * q }]); setCurrentExtra({ name: '', price: '', qty: 1 }); }; // --- SHARING HANDLERS --- const sendWhatsApp = () => { const msg = `*TILE QUOTE: ${projectInfo.quoteReference}*\nDate: ${projectInfo.date}\nCustomer: ${projectInfo.customerName || 'N/A'}\n\n*JOB SUMMARY:*\n${areas.map(a => `- ${a.name}: ${a.totalSqM}m²`).join('\n')}\n\n*TOTAL:* £${grandTotalGross.toFixed(2)}${isVatRegistered ? ' (inc VAT)' : ''}\n\n_Sent via TileQuote UK Pro_`; let phone = projectInfo.phoneNumber.replace(/\D/g, ''); if (phone.startsWith('0')) phone = '44' + phone.substring(1); else if (phone && !phone.startsWith('44')) phone = '44' + phone; window.open(`https://wa.me/${phone}?text=${encodeURIComponent(msg)}`, '_blank'); }; return (
{/* MAIN APP INTERFACE */}

TileQuote UK

{activeTab === 'measure' ? (
{/* Form Section */}

{editingId ? : } {editingId ? 'Amend Room Data' : 'Room Configuration'}

{editingId && }
setCurrentArea({...currentArea, name: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm focus:ring-2 ring-indigo-500" placeholder="e.g. Master Ensuite" />
{currentArea.isManualArea ? (
setCurrentArea({...currentArea, manualArea: e.target.value})} className="w-full bg-amber-50 border-none rounded-xl p-3 text-sm font-bold focus:ring-2 ring-amber-500" placeholder="Enter area manually" />
) : ( <>
setCurrentArea({...currentArea, width: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm" placeholder="0.000" />
setCurrentArea({...currentArea, length: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm" placeholder="0.000" />
)}
setCurrentArea({...currentArea, tilePrice: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm" placeholder="0.00" />
{currentArea.type === 'floor' ? ( <> ) : ( )}
{/* Inventory List */}

Job Inventory ({areas.length})

{areas.map(a => (
{a.type === 'floor' ? : }

{a.name}

{a.totalSqM}m² {a.hasTanking && TANKED} {a.hasCementBoard && BOARDED}
))}
) : (
{/* Quote/Client Tab */}

Client Information

setProjectInfo({...projectInfo, customerName: e.target.value})} className="w-full bg-slate-50 rounded-xl p-3 pl-10 text-sm border-none" placeholder="Customer Name" />
setProjectInfo({...projectInfo, phoneNumber: e.target.value})} className="w-full bg-slate-50 rounded-xl p-3 pl-10 text-sm border-none" placeholder="Mobile (for WhatsApp)" />
Apply 20% VAT?

Estimate Total

£{grandTotalGross.toLocaleString(undefined, {minimumFractionDigits: 2})}

Net Amount

£{subTotalNet.toFixed(2)}

VAT (20%)

£{vatAmount.toFixed(2)}

{/* Material Breakdown */}

Material Breakdown

Tiles ({grandTotalGrossSqM.toFixed(1)}m²)£{totalTileCost.toFixed(2)}
Adhesive ({bagsAdhesive} Bags)£{totalAdhesiveCost.toFixed(2)}
Prep & Consumables£{(totalPrepCost + totalPrimerCost + totalSealantCost).toFixed(2)}
)}
{/* HIDDEN PRINT TEMPLATE */}

Quote

Ref: {projectInfo.quoteReference}

Date: {projectInfo.date}

Service Estimate

Professional Tiling Services

Customer

{projectInfo.customerName || 'N/A'}

{projectInfo.phoneNumber || 'N/A'}

Site Address

{projectInfo.siteAddress || 'N/A'}

{areas.map(a => ( ))}
Item Description Quantity Net Price

{a.name}

{a.type} Tiling + Materials

{a.totalSqM}m² £{a.tileTotal.toFixed(2)}
Labour Charge (Calculated m²) {grandTotalGrossSqM.toFixed(2)}m² £{totalLabor.toFixed(2)}
Net Total:£{subTotalNet.toFixed(2)}
{isVatRegistered && (
VAT (20%):£{vatAmount.toFixed(2)}
)}
TOTAL: £{grandTotalGross.toFixed(2)}

Terms & Validity

This estimate is valid for 30 days from the date above. Material prices are subject to supplier market fluctuations. Preparation requirements not detailed in this inventory may incur additional charges upon site start.