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 */}
{btStatus === 'connected' ? : }
{btStatus === 'connected' ? 'LASER LINKED' : 'CONNECT'}
{activeTab === 'measure' ? (
{/* Form Section */}
{editingId ? : }
{editingId ? 'Amend Room Data' : 'Room Configuration'}
{editingId &&
setEditingId(null)} className="text-[10px] text-red-500 font-bold hover:underline">CANCEL }
setCurrentArea({...currentArea, type:'floor'})} className={`flex-1 py-2.5 rounded-lg text-[10px] font-bold transition-all ${currentArea.type === 'floor' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500'}`}>FLOOR
setCurrentArea({...currentArea, type:'wall'})} className={`flex-1 py-2.5 rounded-lg text-[10px] font-bold transition-all ${currentArea.type === 'wall' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500'}`}>WALL
{currentArea.isManualArea ? (
TOTAL SQM (m²)
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" />
) : (
<>
WIDTH (m) setCurrentArea({...currentArea, width: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm" placeholder="0.000" />
LENGTH (m) setCurrentArea({...currentArea, length: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm" placeholder="0.000" />
>
)}
TILE (£/m²) setCurrentArea({...currentArea, tilePrice: e.target.value})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm" placeholder="0.00" />
WASTE % setCurrentArea({...currentArea, wastePercent: parseInt(e.target.value)})} className="w-full bg-slate-50 border-none rounded-xl p-3 text-sm">5% 10% 15%
{currentArea.type === 'floor' ? (
<>
setCurrentArea({...currentArea, hasCementBoard: !currentArea.hasCementBoard})} className={`flex-1 py-3 rounded-xl border text-[9px] font-black tracking-tighter ${currentArea.hasCementBoard ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-white text-slate-400'}`}>CEMENT BOARD
setCurrentArea({...currentArea, hasAntiCrack: !currentArea.hasAntiCrack})} className={`flex-1 py-3 rounded-xl border text-[9px] font-black tracking-tighter ${currentArea.hasAntiCrack ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-white text-slate-400'}`}>ANTI-CRACK
>
) : (
setCurrentArea({...currentArea, hasTanking: !currentArea.hasTanking})} className={`flex-1 py-3 rounded-xl border text-[9px] font-black tracking-tighter ${currentArea.hasTanking ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-white text-slate-400'}`}>WATERPROOF TANKING
)}
{editingId ? 'UPDATE AREA' : 'SAVE ROOM'}
{/* Inventory List */}
Job Inventory ({areas.length})
{areas.map(a => (
{a.type === 'floor' ? : }
{a.name}
{a.totalSqM}m²
{a.hasTanking && TANKED }
{a.hasCementBoard && BOARDED }
{setCurrentArea(a); setEditingId(a.id); window.scrollTo(0,0)}} className="p-2 text-slate-400 hover:text-indigo-600">
setAreas(areas.filter(x => x.id !== a.id))} className="p-2 text-slate-400 hover:text-red-500">
))}
) : (
{/* Quote/Client Tab */}
setIsVatRegistered(!isVatRegistered)} className={`w-12 h-6 rounded-full relative transition-colors ${isVatRegistered ? 'bg-indigo-600' : 'bg-slate-300'}`}>
Estimate Total
£{grandTotalGross.toLocaleString(undefined, {minimumFractionDigits: 2})}
Net Amount
£{subTotalNet.toFixed(2)}
VAT (20%)
£{vatAmount.toFixed(2)}
SEND VIA WHATSAPP
window.print()} className="bg-indigo-600 text-white font-bold py-5 rounded-2xl flex items-center justify-center gap-3 active:scale-95 shadow-lg"> GENERATE PDF QUOTE
{/* 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)}
)}
setActiveTab('measure')} className={`flex flex-col items-center gap-1 transition-all ${activeTab === 'measure' ? 'text-indigo-600 scale-110' : 'text-slate-400'}`}>Inventory
setActiveTab('quote')} className={`flex flex-col items-center gap-1 transition-all ${activeTab === 'quote' ? 'text-indigo-600 scale-110' : 'text-slate-400'}`}>Quote
{/* 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'}
Item Description
Quantity
Net Price
{areas.map(a => (
{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.
{/* Laser HUD */}
{btStatus === 'connected' && lastBleValue && (
{lastBleValue}m
)}
);
};
export default App;