import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { AreaChart, Area, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from "recharts";
// ─── SEED DATA ────────────────────────────────────────────────────────────────
const SEED = [{"name":"Blueberries","category":"Veg & Produce","packWt":125,"packCost":2.07,"costPerG":0.01656,"supplier":"Central"},{"name":"Raspberrys","category":"Veg & Produce","packWt":125,"packCost":2.88,"costPerG":0.02304,"supplier":"Central"},{"name":"Strawberries","category":"Veg & Produce","packWt":400,"packCost":4.32,"costPerG":0.0108,"supplier":"Central"},{"name":"Flat leaf parsley","category":"Veg & Produce","packWt":500,"packCost":2.16,"costPerG":0.00432,"supplier":"Central"},{"name":"Chives","category":"Veg & Produce","packWt":100,"packCost":1.8,"costPerG":0.018,"supplier":"Central"},{"name":"Coriander","category":"Veg & Produce","packWt":100,"packCost":1.8,"costPerG":0.018,"supplier":"Central"},{"name":"Garlic","category":"Veg & Produce","packWt":1000,"packCost":3.6,"costPerG":0.0036,"supplier":"Central"},{"name":"Rocket","category":"Veg & Produce","packWt":500,"packCost":3.42,"costPerG":0.00684,"supplier":"Central"},{"name":"Avocado","category":"Veg & Produce","packWt":1,"packCost":1.13,"costPerG":1.13,"supplier":"Central"},{"name":"Lemons","category":"Veg & Produce","packWt":1000,"packCost":2.7,"costPerG":0.0027,"supplier":"Central"},{"name":"Fries","category":"Dry Goods","packWt":10000,"packCost":24.7,"costPerG":0.00247,"supplier":"Dunns"},{"name":"Brioche burger bun","category":"Bakery","packWt":70,"packCost":0.5,"costPerG":0.007143,"supplier":"Saltire"},{"name":"Scotch steak mince","category":"Meat","packWt":1000,"packCost":11.4,"costPerG":0.0114,"supplier":"Campbells/Gilmores"},{"name":"Whole chickens","category":"Meat","packWt":1000,"packCost":4.0,"costPerG":0.004,"supplier":"Campbells/Gilmores"},{"name":"Smoked bacon","category":"Meat","packWt":1000,"packCost":14.7,"costPerG":0.0147,"supplier":"Campbells/Gilmores"},{"name":"Chicken Thigh","category":"Meat","packWt":1000,"packCost":3.8,"costPerG":0.0038,"supplier":"Campbells/Gilmores"},{"name":"Sirloin steak","category":"Meat","packWt":1000,"packCost":19.95,"costPerG":0.01995,"supplier":"Campbells/Gilmores"},{"name":"Eggs","category":"Dairy","packWt":360,"packCost":85.5,"costPerG":0.2375,"supplier":"Central"},{"name":"Double cream","category":"Dairy","packWt":2000,"packCost":8.55,"costPerG":0.004275,"supplier":"Central"},{"name":"Butter","category":"Dairy","packWt":200,"packCost":11.82,"costPerG":0.0591,"supplier":"Dunns"},{"name":"Halloumi","category":"Dairy","packWt":250,"packCost":3.06,"costPerG":0.01224,"supplier":"Central"},{"name":"PDO Feta cheese","category":"Dairy","packWt":900,"packCost":9.48,"costPerG":0.010533,"supplier":"Dunns"},{"name":"Compote","category":"Bulk Items","packWt":1000,"packCost":5.18,"costPerG":0.00518,"supplier":"House Prep"},{"name":"Hummus","category":"Bulk Items","packWt":1000,"packCost":6.71,"costPerG":0.00671,"supplier":"House Prep"},{"name":"Barbicoa beef","category":"Bulk Items","packWt":1000,"packCost":15.16,"costPerG":0.01516,"supplier":"House Prep"},{"name":"Herb Mayo","category":"Bulk Items","packWt":1000,"packCost":7.37,"costPerG":0.00737,"supplier":"House Prep"},{"name":"Whipped Avo","category":"Bulk Items","packWt":1000,"packCost":18.97,"costPerG":0.01897,"supplier":"House Prep"},{"name":"Kimchi","category":"Bulk Items","packWt":1000,"packCost":4.72,"costPerG":0.00472,"supplier":"House Prep"}];
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
const CAT_ICONS = {"Veg & Produce":"🥦","Meat":"🥩","Dairy":"🧀","Bakery":"🍞","Dry Goods":"🥫","Bulk Items":"📦"};
const CAT_COLORS = {"Veg & Produce":"#4a9e82","Meat":"#d4724a","Dairy":"#c8981a","Bakery":"#8b6fd4","Dry Goods":"#4a7fc4","Bulk Items":"#c46f9a"};
const SUPPLIERS = ["Central","Dunns","Campbells/Gilmores","Saltire","Cressco","House Prep","Other"];
const ROLES = ["Head Chef","Sous Chef","Chef de Partie","Kitchen Porter","FOH Manager","Waiter/Waitress","Bar Staff","Barista","Supervisor"];
const CONTRACT = ["Full Time","Part Time","Zero Hours","Casual"];
const DAYS = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
const TARGET_FOOD = 0.30;
const TARGET_LAB = 0.28; // 28% labour target
const VAT = 0.20;
const TABS = [
{id:"dash", icon:"⚡", label:"Overview"},
{id:"recipe", icon:"🍽", label:"Recipes"},
{id:"library", icon:"🧪", label:"Library"},
{id:"invoice", icon:"📷", label:"Invoices"},
{id:"stock", icon:"📦", label:"Stock"},
{id:"labour", icon:"📊", label:"Labour"},
{id:"staff", icon:"👥", label:"Staff"},
];
// ─── HELPERS ──────────────────────────────────────────────────────────────────
const store = { get:k=>{try{return JSON.parse(localStorage.getItem(k)||"null")}catch{return null}}, set:(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch{}} };
const uid = () => Date.now()+Math.random();
const fmtGBP = n => `£${Number(n).toFixed(2)}`;
const cosCol = c => c>TARGET_FOOD?"#c0392b":c>0.25?"#d68910":"#1e8449";
const cosBg = c => c>TARGET_FOOD?"rgba(192,57,43,0.08)":c>0.25?"rgba(214,137,16,0.08)":"rgba(30,132,73,0.08)";
const cosLbl = c => c>TARGET_FOOD?`🔴 ${(c*100).toFixed(1)}%`:c>0.25?`🟡 ${(c*100).toFixed(1)}%`:`✅ ${(c*100).toFixed(1)}%`;
const labCol = p => p>TARGET_LAB?"#c0392b":p>0.24?"#d68910":"#1e8449";
const initials = name => name.split(" ").map(w=>w[0]).join("").slice(0,2).toUpperCase();
const roleColor = role => ({
"Head Chef":"#c0392b","Sous Chef":"#d4724a","Chef de Partie":"#c8981a",
"Kitchen Porter":"#8b6fd4","FOH Manager":"#1e8449","Waiter/Waitress":"#4a7fc4",
"Bar Staff":"#4a9e82","Barista":"#9b7230","Supervisor":"#2e7560"
}[role]||"#888");
// ─── LOGO ─────────────────────────────────────────────────────────────────────
const Logo = ({size=36}) => (
);
// ─── GP RING ──────────────────────────────────────────────────────────────────
const GPRing = ({cos,size=68}) => {
const gp=Math.max(0,1-cos),r=size*.38,cx=size/2,cy=size/2,sw=size*.1;
const circ=2*Math.PI*r,dash=gp*circ,col=cosCol(cos);
return (
{(gp*100).toFixed(0)}%
GP
);
};
// ─── LABOUR % RING ────────────────────────────────────────────────────────────
const LabRing = ({pct,size=68,target=TARGET_LAB}) => {
const r=size*.38,cx=size/2,cy=size/2,sw=size*.1;
const circ=2*Math.PI*r,dash=Math.min(pct,1)*circ,col=labCol(pct);
return (
{/* target marker at 28% */}
{(pct*100).toFixed(1)}%
LAB
);
};
let _setToast;
const toast = msg => _setToast?.({msg,id:uid()});
// ─── LINE ROW — outside App so it never remounts on render ───────────────────
function LineRow({line,library,activeDD,rSearch,ddRes,updLine,rmLine,setActiveDD,setRSearch}) {
const ing=library.find(i=>i.id===line.ingId);
const isOpen=activeDD===line.id;
const cost=useMemo(()=>{
if(!ing||!line.qty)return null;
const q=parseFloat(line.qty)||0;
return line.unit==="g"?q*ing.costPerG:line.unit==="kg"?q*1000*ing.costPerG:line.unit==="each"?q*ing.packCost:q*ing.costPerG;
},[ing,line.qty,line.unit]);
return (
{setActiveDD(isOpen?null:line.id);setRSearch("");}}>
{ing?<>
{ing.name}
{CAT_ICONS[ing.category]} · £{ing.costPerG.toFixed(4)}/g
>:
+ choose ingredient
}
{cost!==null&&
{fmtGBP(cost)}
}
{isOpen&&(
setRSearch(e.target.value)} autoFocus/>
{ddRes.map(i=>
{updLine(line.id,"ingId",i.id);setActiveDD(null);setRSearch("");}}>
{CAT_ICONS[i.category]} {i.name}
£{i.costPerG.toFixed(4)}/g · {i.supplier}
)}
{!ddRes.length&&
No results
}
)}
updLine(line.id,"qty",e.target.value)} min={0} step={0.1}/>
updLine(line.id,"unit",e.target.value)} style={{background:"var(--bg)",border:"1.5px solid var(--border)",borderRadius:"var(--r2)",color:"var(--ink)",fontFamily:"inherit",fontSize:10,padding:"7px 3px",cursor:"pointer",outline:"none"}}>
g kg ml each
rmLine(line.id)}>×
);
}
// ═════════════════════════════════════════════════════════════════════════════
// MAIN APP
// ═════════════════════════════════════════════════════════════════════════════
export default function App() {
// ── AUTH ──────────────────────────────────────────────────────────────────
const [user,setUser] = useState(()=>store.get("fop_user"));
const [authMode,setAuthMode] = useState("login");
const [aForm,setAForm] = useState({name:"",email:"",password:"",venue:""});
const [aErr,setAErr] = useState("");
// ── CORE DATA ─────────────────────────────────────────────────────────────
const [tab,setTab] = useState("dash");
const [library,setLibrary] = useState(()=>store.get("fop_lib7")||SEED.map((i,x)=>({...i,id:x+1})));
const [recipes,setRecipes] = useState(()=>store.get("fop_rec7")||[]);
const [invoices,setInvoices] = useState(()=>store.get("fop_inv7")||[]);
const [stock,setStock] = useState(()=>store.get("fop_stk7")||{});
const [labour,setLabour] = useState(()=>store.get("fop_lab7")||[]);
const [staff,setStaff] = useState(()=>store.get("fop_sta7")||[]);
const [roster,setRoster] = useState(()=>store.get("fop_ros7")||{}); // {YYYY-WW: {staffId:[{day,start,end,role}]}}
// ── TOAST ─────────────────────────────────────────────────────────────────
const [toastS,setToastS] = useState(null);
_setToast=setToastS;
useEffect(()=>{if(toastS){const t=setTimeout(()=>setToastS(null),3200);return()=>clearTimeout(t);}},[toastS]);
// ── PERSIST ───────────────────────────────────────────────────────────────
useEffect(()=>store.set("fop_lib7",library),[library]);
useEffect(()=>store.set("fop_rec7",recipes),[recipes]);
useEffect(()=>store.set("fop_inv7",invoices),[invoices]);
useEffect(()=>store.set("fop_stk7",stock),[stock]);
useEffect(()=>store.set("fop_lab7",labour),[labour]);
useEffect(()=>store.set("fop_sta7",staff),[staff]);
useEffect(()=>store.set("fop_ros7",roster),[roster]);
// ── CUSTOM API ACTIONS ────────────────────────────────────────────────────
const saveCustomApi = form => {
if(!form.name||!form.url)return;
const entry={...form,id:form.id||uid(),addedAt:new Date().toISOString(),status:"untested"};
if(form.id){setCustomApis(p=>p.map(a=>a.id===form.id?entry:a));}
else{setCustomApis(p=>[...p,entry]);}
toast(`${form.name} saved ✓`);setCustomApiForm(null);
};
const delCustomApi = id=>{setCustomApis(p=>p.filter(a=>a.id!==id));toast("API removed");};
const testCustomApi = async(api)=>{
setTestingApi(api.id);setTestResult(p=>({...p,[api.id]:null}));
try{
const headers={"Content-Type":"application/json"};
if(api.key&&api.keyHeader){headers[api.keyHeader]=api.key;}
const res=await fetch(api.url,{method:api.method||"GET",headers,signal:AbortSignal.timeout(8000)});
let preview="";
try{const d=await res.json();preview=JSON.stringify(d).slice(0,140)+"…";}catch{preview=`HTTP ${res.status}`;}
setCustomApis(p=>p.map(a=>a.id===api.id?{...a,status:res.ok?"connected":"error",lastTested:new Date().toISOString(),lastStatus:res.status}:a));
setTestResult(p=>({...p,[api.id]:{ok:res.ok,preview,status:res.status}}));
toast(res.ok?`${api.name} ✓`:`${api.name} returned ${res.status}`);
}catch(e){
setCustomApis(p=>p.map(a=>a.id===api.id?{...a,status:"error",lastTested:new Date().toISOString()}:a));
setTestResult(p=>({...p,[api.id]:{ok:false,preview:e.message}}));
toast(`${api.name}: ${e.message.slice(0,40)}`);
}
setTestingApi(null);
};
// ── RECIPE STATE ──────────────────────────────────────────────────────────
const [rName,setRName] = useState("");
const [rSec,setRSec] = useState("Brunch");
const [rPrice,setRPrice] = useState("");
const [rWaste,setRWaste] = useState(0.10);
const [rLines,setRLines] = useState([{id:1,ingId:null,qty:"",unit:"g"}]);
const [rSearch,setRSearch] = useState("");
const [activeDD,setActiveDD] = useState(null);
const [editId,setEditId] = useState(null);
// ── LIB STATE ─────────────────────────────────────────────────────────────
const [libQ,setLibQ] = useState("");
const [libCat,setLibCat] = useState("All");
const [ingForm,setIngForm] = useState(null);
// ── INVOICE STATE ─────────────────────────────────────────────────────────
const [invImgs,setInvImgs] = useState([]);
const [invScan,setInvScan] = useState(false);
const [invProg,setInvProg] = useState(0);
const [invLbl,setInvLbl] = useState("");
const [invExt,setInvExt] = useState(null);
const [invErr,setInvErr] = useState("");
const invRef = useRef();
// ── STOCK STATE ───────────────────────────────────────────────────────────
const [stCat,setStCat] = useState("All");
const [stQ,setStQ] = useState("");
// ── LABOUR STATE ──────────────────────────────────────────────────────────
const [lDate,setLDate] = useState(new Date().toISOString().slice(0,10));
const [lSales,setLSales] = useState("");
const [lFood,setLFood] = useState("");
const [lHours,setLHours] = useState("");
const [lRate,setLRate] = useState("12.50");
const [lNote,setLNote] = useState("");
const [aiLoad,setAiLoad] = useState(false);
const [aiTxt,setAiTxt] = useState(null);
// ── STAFF STATE ───────────────────────────────────────────────────────────
const [staffView,setStaffView] = useState("roster"); // roster | team | analytics | connect
const [staffForm,setStaffForm] = useState(null);
const [rosterWeek,setRosterWeek] = useState(()=>{ const d=new Date(); const mon=new Date(d.setDate(d.getDate()-d.getDay()+1)); return mon.toISOString().slice(0,10); });
const [shiftForm,setShiftForm] = useState(null); // {staffId,day}
const [aiRosterLoad,setAiRosterLoad] = useState(false);
const [aiRosterTxt,setAiRosterTxt] = useState(null);
const [posConnected,setPosConnected] = useState(()=>store.get("fop_pos_connected")||false);
const [schedConnected,setSchedConnected] = useState(()=>store.get("fop_sched_connected")||false);
const [connectingApi,setConnectingApi] = useState(null);
const [customApis,setCustomApis] = useState(()=>store.get("fop_custom_apis")||[]);
const [customApiForm,setCustomApiForm] = useState(null);
const [testingApi,setTestingApi] = useState(null);
const [testResult,setTestResult] = useState({});
useEffect(()=>store.set("fop_custom_apis",customApis),[customApis]);
// ── COMPUTED ──────────────────────────────────────────────────────────────
const isDemo = labour.length < 3;
const dashData = useMemo(()=>{
if(!isDemo){return [...labour].slice(0,7).reverse().map(e=>({day:new Date(e.date).toLocaleDateString("en-GB",{weekday:"short"}),sales:e.sales,labour:Math.round(e.labour),food:Math.round(e.food||0),covers:Math.round(e.sales/22)}));}
return [{day:"Mon",sales:1840,labour:420,food:510,covers:68},{day:"Tue",sales:2100,labour:480,food:580,covers:78},{day:"Wed",sales:2650,labour:520,food:720,covers:98},{day:"Thu",sales:3100,labour:580,food:860,covers:115},{day:"Fri",sales:4200,labour:720,food:1150,covers:158},{day:"Sat",sales:4800,labour:780,food:1300,covers:180},{day:"Sun",sales:3600,labour:650,food:990,covers:135}];
},[labour,isDemo]);
const wkSales=dashData.reduce((a,d)=>a+d.sales,0),wkLab=dashData.reduce((a,d)=>a+d.labour,0),wkFood=dashData.reduce((a,d)=>a+d.food,0),wkGP=wkSales-wkLab-wkFood;
const rc = useMemo(()=>{
const base=rLines.reduce((a,l)=>{const ing=library.find(i=>i.id===l.ingId);if(!ing||!l.qty)return a;const q=parseFloat(l.qty)||0,c=l.unit==="g"?q*ing.costPerG:l.unit==="kg"?q*1000*ing.costPerG:l.unit==="each"?q*ing.packCost:q*ing.costPerG;return a+c;},0);
const adj=base*(1+rWaste),price=parseFloat(rPrice)||0,cos=price>0?adj/price:null,min=adj/TARGET_FOOD;
return{base,adj,cos,min,sug:min*(1+VAT),price};
},[rLines,library,rPrice,rWaste]);
const labStats = useMemo(()=>{if(!labour.length)return null;const e=labour.slice(0,7),ts=e.reduce((a,x)=>a+x.sales,0),tl=e.reduce((a,x)=>a+x.labour,0),tf=e.reduce((a,x)=>a+(x.food||0),0);return{ts,tl,tf,lp:tl/ts,fp:tf>0?tf/ts:null,cp:(tl+tf)/ts,n:e.length};},[labour]);
// ── ROSTER COMPUTED ───────────────────────────────────────────────────────
const weekRoster = useMemo(()=>roster[rosterWeek]||{},[roster,rosterWeek]);
// Parse shift time string "HH:MM" to decimal hours
const shiftHrs = sh => {
const [sh1,sm1]=(sh.start||"00:00").split(":").map(Number);
const [sh2,sm2]=(sh.end||"00:00").split(":").map(Number);
return Math.max(0,(sh2*60+sm2)-(sh1*60+sm1))/60;
};
// Historical averages by day of week from labour logs
const dayAverages = useMemo(()=>{
const totals={Mon:{sales:0,n:0},Tue:{sales:0,n:0},Wed:{sales:0,n:0},Thu:{sales:0,n:0},Fri:{sales:0,n:0},Sat:{sales:0,n:0},Sun:{sales:0,n:0}};
labour.forEach(e=>{
const d=new Date(e.date);
const day=DAYS[d.getDay()===0?6:d.getDay()-1];
if(totals[day]){totals[day].sales+=e.sales;totals[day].n++;}
});
return Object.fromEntries(Object.entries(totals).map(([d,v])=>[d,{avg:v.n>0?v.sales/v.n:0,n:v.n}]));
},[labour]);
// Calculate roster labour cost for the week
const rosterCost = useMemo(()=>{
let total=0;
staff.forEach(s=>{
(weekRoster[s.id]||[]).forEach(sh=>{ total+=shiftHrs(sh)*s.rate; });
});
return total;
},[staff,weekRoster]);
// Per-day roster cost
const rosterDayCost = useMemo(()=>{
const costs={};
DAYS.forEach(d=>costs[d]=0);
staff.forEach(s=>{
(weekRoster[s.id]||[]).forEach(sh=>{ costs[sh.day]=(costs[sh.day]||0)+shiftHrs(sh)*s.rate; });
});
return costs;
},[staff,weekRoster]);
const estWeekSales = useMemo(()=>DAYS.reduce((a,d)=>a+(dayAverages[d]?.avg||0),0),[dayAverages]);
const rosterLabPct = useMemo(()=>estWeekSales>0?rosterCost/estWeekSales:null,[rosterCost,estWeekSales]);
// Hours per staff member this week
const staffHours = useMemo(()=>{
const h={};
staff.forEach(s=>{
h[s.id]=(weekRoster[s.id]||[]).reduce((a,sh)=>a+shiftHrs(sh),0);
});
return h;
},[staff,weekRoster]);
// ── RECIPE ACTIONS ────────────────────────────────────────────────────────
const calcLines=useCallback((lines,lib)=>lines.map(l=>{const ing=lib.find(i=>i.id===l.ingId);if(!ing||!l.qty)return{...l,cost:0};const q=parseFloat(l.qty)||0,cost=l.unit==="g"?q*ing.costPerG:l.unit==="kg"?q*1000*ing.costPerG:l.unit==="each"?q*ing.packCost:q*ing.costPerG;return{...l,cost};}),[]);
const recalcRecipes=useCallback(nl=>{setRecipes(prev=>prev.map(r=>{const lines=calcLines(r.lines||[],nl);const base=lines.reduce((a,l)=>a+(l.cost||0),0),adj=base*(1+(r.waste||0.1)),cos=r.price>0?adj/r.price:null,min=adj/TARGET_FOOD;return{...r,lines,base,adj,cos,min,sug:min*(1+VAT)};}));},[calcLines]);
const saveRecipe=()=>{
if(!rName.trim()||!rLines.some(l=>l.ingId))return;
const lines=calcLines(rLines.filter(l=>l.ingId&&l.qty),library);
const base=lines.reduce((a,l)=>a+(l.cost||0),0),adj=base*(1+rWaste),price=parseFloat(rPrice)||0;
const cos=price>0?adj/price:null,min=adj/TARGET_FOOD;
const r={id:editId||uid(),name:rName,section:rSec,price,waste:rWaste,lines,base,adj,cos,min,sug:min*(1+VAT),savedAt:new Date().toISOString()};
if(editId){setRecipes(p=>p.map(x=>x.id===editId?r:x));}else{setRecipes(p=>[r,...p]);}
toast("Recipe saved ✓");setRName("");setRPrice("");setRWaste(0.10);setEditId(null);setRLines([{id:uid(),ingId:null,qty:"",unit:"g"}]);setTab("recipe");
};
const editRecipe=r=>{setRName(r.name);setRSec(r.section);setRPrice(r.price?.toString()||"");setRWaste(r.waste||0.1);setRLines(r.lines||[]);setEditId(r.id);setTab("recipe");};
const addLine=()=>setRLines(p=>[...p,{id:uid(),ingId:null,qty:"",unit:"g"}]);
const rmLine=id=>setRLines(p=>p.filter(l=>l.id!==id));
const updLine=(id,f,v)=>setRLines(p=>p.map(l=>l.id===id?{...l,[f]:v}:l));
// ── LIBRARY ACTIONS ───────────────────────────────────────────────────────
const saveIng=form=>{if(!form.name||!form.packWt||!form.packCost)return;const cpg=parseFloat(form.packCost)/parseFloat(form.packWt);const entry={...form,packWt:parseFloat(form.packWt),packCost:parseFloat(form.packCost),costPerG:cpg};let nl;if(form.id){nl=library.map(i=>i.id===form.id?{...i,...entry}:i);}else{nl=[...library,{...entry,id:uid()}];}setLibrary(nl);recalcRecipes(nl);toast(form.id?"Price updated — recipes recalculated ✓":"Ingredient added ✓");setIngForm(null);};
const delIng=id=>{setLibrary(p=>p.filter(i=>i.id!==id));setIngForm(null);toast("Deleted");};
// ── INVOICE ACTIONS ───────────────────────────────────────────────────────
const addImgs=useCallback(files=>{Array.from(files).filter(f=>f.type.startsWith("image/")).forEach(f=>{const r=new FileReader();r.onload=e=>setInvImgs(p=>[...p,{id:uid(),src:e.target.result,b64:e.target.result.split(",")[1],mime:f.type}]);r.readAsDataURL(f);});},[]);
const scanInvoice=async()=>{
if(!invImgs.length)return;
setInvScan(true);setInvExt(null);setInvErr("");setInvProg(10);setInvLbl("Preparing…");
try{
const ib=invImgs.map(i=>({type:"image",source:{type:"base64",media_type:i.mime,data:i.b64}}));
setInvProg(35);setInvLbl("Claude reading invoice…");
const res=await fetch("https://api.anthropic.com/v1/messages",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({model:"claude-sonnet-4-20250514",max_tokens:4096,system:"You are an expert at reading UK restaurant supplier invoices. Return ONLY valid JSON, no markdown.",messages:[{role:"user",content:[...ib,{type:"text",text:`Extract ALL line items. Return ONLY: {"invoice_number":"","invoice_date":"DD/MM/YYYY","supplier_name":"","invoice_total":0,"items":[{"name":"","unit":"kg","pack_wt_g":null,"qty":1,"unit_price":0,"line_total":0}]}`}]}]})});
setInvProg(72);setInvLbl("Matching to library…");
if(!res.ok)throw new Error(`API ${res.status}`);
const data=await res.json();const raw=data.content?.find(b=>b.type==="text")?.text||"";
if(!raw.trim())throw new Error("Empty response");
let parsed;try{parsed=JSON.parse(raw.replace(/^```json?\s*/im,"").replace(/```\s*$/m,"").trim());}catch{const m=raw.match(/{[\s\S]*}/);if(!m)throw new Error("Parse failed");parsed=JSON.parse(m[0]);}
if(!parsed.items?.length)throw new Error("No items found");
const items=parsed.items.map((item,i)=>{const match=library.find(l=>item.name&&(l.name.toLowerCase().includes(item.name.toLowerCase().split(" ")[0])||item.name.toLowerCase().includes(l.name.toLowerCase().split(" ")[0])));return{...item,id:i,selected:true,libId:match?.id||null,libName:match?.name||null};});
let sup=parsed.supplier_name||"Other";for(const s of SUPPLIERS){if(sup.toLowerCase().includes(s.toLowerCase())){sup=s;break;}}
setInvExt({items,meta:{inv_number:parsed.invoice_number||`INV-${Date.now()}`,date:parsed.invoice_date||new Date().toLocaleDateString("en-GB"),supplier:sup,total:parsed.invoice_total||items.reduce((a,i)=>a+(i.line_total||0),0)}});
setInvProg(100);setInvLbl(`✓ ${items.length} items extracted`);setTimeout(()=>setInvProg(0),2000);
}catch(e){setInvErr(e.message);setInvProg(0);}finally{setInvScan(false);}
};
const saveInvoice=()=>{
if(!invExt)return;
const sel=invExt.items.filter(i=>i.selected);let updated=0;let nl=[...library];
sel.forEach(item=>{if(item.libId&&item.unit_price>0&&item.pack_wt_g>0){nl=nl.map(l=>l.id===item.libId?{...l,packCost:item.unit_price,packWt:item.pack_wt_g,costPerG:item.unit_price/item.pack_wt_g}:l);updated++;}});
setLibrary(nl);recalcRecipes(nl);
setInvoices(p=>[{id:uid(),savedAt:new Date().toISOString(),...invExt.meta,items:sel.map(({id:_,selected:__,libId:___,libName:____,...r})=>r)},...p]);
toast(`${sel.length} items saved · ${updated} prices updated · recipes recalculated ✓`);
setInvExt(null);setInvImgs([]);
};
// ── STOCK ─────────────────────────────────────────────────────────────────
const stockValue=useMemo(()=>library.reduce((a,i)=>a+(parseFloat(stock[i.id])||0)*i.packCost,0),[library,stock]);
const stockCounted=useMemo(()=>library.filter(i=>(parseFloat(stock[i.id])||0)>0),[library,stock]);
const filtStock=useMemo(()=>{const q=stQ.toLowerCase();return library.filter(i=>(stCat==="All"||i.category===stCat)&&(!q||i.name.toLowerCase().includes(q)));},[library,stCat,stQ]);
// ── LABOUR ────────────────────────────────────────────────────────────────
const saveLabour=()=>{
if(!lSales||!lHours)return;
const labCost=parseFloat(lHours)*parseFloat(lRate),sales=parseFloat(lSales),food=parseFloat(lFood)||0;
setLabour(p=>[{id:uid(),date:lDate,sales,food,labour:labCost,hours:parseFloat(lHours),rate:parseFloat(lRate),labourPct:labCost/sales,foodPct:food>0?food/sales:null,combinedPct:(labCost+food)/sales,note:lNote},...p]);
setLSales("");setLFood("");setLHours("");setLNote("");toast("Trading day logged ✓");
};
const askAI=async()=>{
setAiLoad(true);setAiTxt(null);
const cheap=[...library].sort((a,b)=>a.costPerG-b.costPerG).slice(0,12).map(i=>`${i.name}(£${i.costPerG.toFixed(4)}/g)`).join(", ");
const exist=recipes.slice(0,5).map(r=>r.name).join(", ");
try{const res=await fetch("https://api.anthropic.com/v1/messages",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({model:"claude-sonnet-4-20250514",max_tokens:600,system:"You are a food cost consultant for a Scottish restaurant. Be specific and practical. Max 220 words.",messages:[{role:"user",content:`Cheapest ingredients: ${cheap}. Current menu: ${exist||"none"}. Suggest 3 new high-margin dishes with rough food cost %, suggested menu price, and why it works.`}]})});const d=await res.json();setAiTxt(d.content?.[0]?.text||"No response.");}catch(e){setAiTxt("AI unavailable.");}
setAiLoad(false);
};
// ── STAFF ACTIONS ─────────────────────────────────────────────────────────
const saveStaff = form => {
if(!form.name||!form.role)return;
const entry={...form,rate:parseFloat(form.rate)||12.50,contractHours:parseFloat(form.contractHours)||0};
if(form.id){setStaff(p=>p.map(s=>s.id===form.id?{...s,...entry}:s));toast("Staff updated ✓");}
else{setStaff(p=>[...p,{...entry,id:uid(),joinedAt:new Date().toISOString()}]);toast("Staff member added ✓");}
setStaffForm(null);
};
const delStaff = id=>{setStaff(p=>p.filter(s=>s.id!==id));setStaffForm(null);toast("Staff member removed");};
// Add/remove shift on roster
const addShift = (staffId,shift)=>{
setRoster(p=>({...p,[weekKey]:{...(p[weekKey]||{}), [staffId]:[...((p[weekKey]||{})[staffId]||[]),{...shift,id:uid()}]}}));
setShiftForm(null);toast("Shift added ✓");
};
const removeShift = (staffId,shiftId)=>{
setRoster(p=>({...p,[weekKey]:{...(p[weekKey]||{}), [staffId]:((p[weekKey]||{})[staffId]||[]).filter(s=>s.id!==shiftId)}}));
toast("Shift removed");
};
// Navigate weeks
const changeWeek = delta=>{
const d=new Date(rosterWeek);
d.setDate(d.getDate()+delta*7);
setRosterWeek(d.toISOString().slice(0,10));
};
// Simulate API connection
const connectApi = async(api)=>{
setConnectingApi(api);
await new Promise(r=>setTimeout(r,2000));
if(api==="pos"){setPosConnected(true);store.set("fop_pos_connected",true);toast("POS connected — pulling sales data ✓");}
if(api==="sched"){setSchedConnected(true);store.set("fop_sched_connected",true);toast("Scheduling connected — importing staff ✓");}
setConnectingApi(null);
};
// AI roster builder
const buildAIRoster = async()=>{
if(!staff.length){toast("Add staff members first");return;}
setAiRosterLoad(true);setAiRosterTxt(null);
const staffSummary=staff.map(s=>`${s.name}(${s.role},£${s.rate}/hr,${s.contractHours}h/wk)`).join(", ");
const avgsByDay=DAYS.map(d=>`${d}:£${(dayAverages[d]?.avg||0).toFixed(0)}`).join(", ");
const busyDays=DAYS.filter(d=>dayAverages[d]?.avg>0).sort((a,b)=>(dayAverages[b]?.avg||0)-(dayAverages[a]?.avg||0)).slice(0,3).join(", ");
try{
const res=await fetch("https://api.anthropic.com/v1/messages",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({model:"claude-sonnet-4-20250514",max_tokens:800,system:`You are a restaurant roster expert for a Scottish venue. The labour target is ${TARGET_LAB*100}% of sales. Be specific with shift times and costs. Max 300 words.`,messages:[{role:"user",content:`Staff: ${staffSummary}. Historical average sales by day: ${avgsByDay}. Busiest days: ${busyDays}. Estimated week sales: £${estWeekSales.toFixed(0)}. Labour budget at ${TARGET_LAB*100}%: £${(estWeekSales*TARGET_LAB).toFixed(0)}. Build the most cost-efficient roster. Suggest specific shift times for each person each day, highlight where to cut hours on quiet days, and flag any days where we risk overstaffing. Also note total estimated weekly labour cost.`}]})});
const d=await res.json();setAiRosterTxt(d.content?.[0]?.text||"No response.");
}catch(e){setAiRosterTxt("AI unavailable.");}
setAiRosterLoad(false);
};
// ── AUTH ACTIONS ──────────────────────────────────────────────────────────
const handleAuth=()=>{
setAErr("");if(!aForm.email||!aForm.password){setAErr("Please fill in all fields");return;}
if(authMode==="register"&&!aForm.name){setAErr("Please enter your name");return;}
const users=store.get("fop_users")||[];
if(authMode==="register"){if(users.find(u=>u.email===aForm.email)){setAErr("Email already registered");return;}const nu={id:uid(),name:aForm.name,email:aForm.email,password:aForm.password,venue:aForm.venue||"My Venue",createdAt:new Date().toISOString()};store.set("fop_users",[...users,nu]);store.set("fop_user",nu);setUser(nu);toast(`Welcome, ${nu.name.split(" ")[0]}! 🎉`);}
else{const u=users.find(u=>u.email===aForm.email&&u.password===aForm.password);if(!u){setAErr("Email or password incorrect");return;}store.set("fop_user",u);setUser(u);toast(`Welcome back, ${u.name.split(" ")[0]}! 👋`);}
};
const logout=()=>{store.set("fop_user",null);setUser(null);};
// ── FILTERED ──────────────────────────────────────────────────────────────
const filtLib=useMemo(()=>{const q=libQ.toLowerCase();return library.filter(i=>(libCat==="All"||i.category===libCat)&&(!q||i.name.toLowerCase().includes(q)));},[library,libQ,libCat]);
const ddRes=useMemo(()=>{if(!rSearch)return library.slice(0,12);const q=rSearch.toLowerCase();return library.filter(i=>i.name.toLowerCase().includes(q)).slice(0,10);},[library,rSearch]);
// ─────────────────────────────────────────────────────────────────────────
// CSS
// ─────────────────────────────────────────────────────────────────────────
const css=`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f0ede8;--bg2:#e8e4de;--white:#ffffff;--card:#ffffff;
--border:#ddd9d2;--border2:#ccc8c0;
--ink:#1a1a1a;--ink2:#4a4a4a;--ink3:#888880;--ink4:#b8b4ae;
--teal:#3d8b72;--teal2:#2e7560;--teal-dim:rgba(61,139,114,0.1);
--green:#1e7a40;--red:#c0392b;--amber:#b8860b;--blue:#2c6fa8;
--shadow:0 1px 4px rgba(0,0,0,0.08);--shadow2:0 4px 16px rgba(0,0,0,0.1);
--r:16px;--r2:12px;--r3:8px;
}
body{background:var(--bg);color:var(--ink);font-family:'Inter',system-ui,sans-serif;min-height:100vh;-webkit-font-smoothing:antialiased;font-size:14px}
::-webkit-scrollbar{width:3px;height:3px}::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes slideIn{from{opacity:0;transform:translateY(-16px)}to{opacity:1;transform:none}}
.fade{animation:fadeUp .2s ease}
/* LOGIN */
.lw{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg)}
.lc{background:var(--white);border:1px solid var(--border);border-radius:var(--r);padding:36px 28px;width:100%;max-width:380px;box-shadow:var(--shadow2)}
.ll{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:8px}
.lw-mark{font-size:18px;font-weight:800;color:var(--ink)}
.lw-mark span{color:var(--teal)}
.lw-sub{font-size:11px;color:var(--ink3);text-align:center;margin-bottom:24px}
.l-tabs{display:flex;background:var(--bg);border-radius:var(--r3);padding:3px;gap:3px;margin-bottom:22px;border:1px solid var(--border)}
.l-tab{flex:1;padding:8px;border:none;border-radius:6px;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;background:transparent;color:var(--ink3);transition:all .15s}
.l-tab.on{background:var(--teal);color:white}
/* SHELL */
.root{display:flex;flex-direction:column;min-height:100vh;max-width:480px;margin:0 auto;padding-bottom:76px;background:var(--bg)}
/* HEADER */
.hdr{background:var(--white);border-bottom:1px solid var(--border);padding:12px 16px;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow)}
.hbrand{display:flex;align-items:center;gap:10px}
.hname{font-size:15px;font-weight:800;color:var(--ink);letter-spacing:-.2px}
.hsub{font-size:9px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--ink3);margin-top:1px}
.hright{display:flex;align-items:center;gap:8px}
.hstats{font-size:10px;color:var(--ink3);text-align:right;line-height:1.8}
.lo-btn{background:transparent;border:1px solid var(--border2);border-radius:var(--r3);color:var(--ink3);font-size:10px;padding:5px 10px;cursor:pointer;font-family:inherit;transition:all .15s}
.lo-btn:hover{border-color:var(--red);color:var(--red)}
/* NAV */
.nav{position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:480px;background:var(--white);border-top:1px solid var(--border);display:flex;z-index:100;padding:4px 0 env(safe-area-inset-bottom);box-shadow:0 -2px 12px rgba(0,0,0,0.06)}
.nb{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding:6px 2px;cursor:pointer;border:none;background:transparent;color:var(--ink4);font-family:inherit;font-size:8.5px;font-weight:700;letter-spacing:.02em;transition:color .15s;-webkit-tap-highlight-color:transparent}
.nb.on{color:var(--teal)}
.nb .ico{font-size:18px;line-height:1.2}
/* PAGE */
.pg{padding:18px 16px;flex:1}
.pt{font-size:26px;font-weight:900;letter-spacing:-.5px;color:var(--ink);margin-bottom:2px}
.ps{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:16px}
/* CARDS */
.card{background:var(--white);border:1px solid var(--border);border-radius:var(--r);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
.card.teal{border-color:rgba(61,139,114,.25);background:rgba(61,139,114,.04)}
.card.red{border-color:rgba(192,57,43,.2);background:rgba(192,57,43,.04)}
.card.blue{border-color:rgba(44,111,168,.2);background:rgba(44,111,168,.04)}
.card.amber{border-color:rgba(184,134,11,.2);background:rgba(184,134,11,.04)}
.cl{font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:12px;display:flex;align-items:center;gap:6px}
.cl::after{content:'';flex:1;height:1px;background:var(--border)}
/* INPUTS */
.inp{width:100%;background:var(--bg);border:1.5px solid var(--border);border-radius:var(--r2);color:var(--ink);font-family:inherit;font-size:14px;padding:11px 13px;outline:none;transition:border-color .15s;-webkit-appearance:none}
.inp:focus{border-color:var(--teal);background:var(--white)}
.inp::placeholder{color:var(--ink4)}
.sel{width:100%;background:var(--bg);border:1.5px solid var(--border);border-radius:var(--r2);color:var(--ink);font-family:inherit;font-size:13px;padding:10px 12px;cursor:pointer;outline:none;-webkit-appearance:none}
.sel:focus{border-color:var(--teal)}
/* BUTTONS */
.btn{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:var(--r2);cursor:pointer;font-family:inherit;font-weight:700;font-size:13px;padding:11px 18px;transition:all .15s;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.bteal{background:linear-gradient(135deg,var(--teal),var(--teal2));color:white;box-shadow:0 2px 8px rgba(61,139,114,.3)}.bteal:hover{box-shadow:0 4px 16px rgba(61,139,114,.4);transform:translateY(-1px)}
.bghost{background:transparent;color:var(--ink2);border:1.5px solid var(--border2)}.bghost:hover{border-color:var(--teal);color:var(--teal)}
.bred{background:rgba(192,57,43,.06);color:var(--red);border:1.5px solid rgba(192,57,43,.2)}.bred:hover{background:rgba(192,57,43,.12)}
.bai{background:linear-gradient(135deg,#5b6ef0,#8b5cf6);color:#fff;border:none}
.btn:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
.btn-full{width:100%;justify-content:center;padding:13px}
.btn-sm{padding:7px 13px;font-size:12px}
.btn-xs{padding:4px 10px;font-size:11px}
/* KPIs */
.kr{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
.kr3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px}
.kpi{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:14px;box-shadow:var(--shadow)}
.kl{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--ink3);margin-bottom:4px}
.kv{font-size:22px;font-weight:900;letter-spacing:-.5px;color:var(--ink)}
.kv.g{color:var(--green)}.kv.r{color:var(--red)}.kv.a{color:var(--amber)}.kv.t{color:var(--teal)}.kv.b{color:var(--blue)}
.ks{font-size:9px;color:var(--ink3);margin-top:2px}
/* FORM */
.fr{display:flex;gap:9px;margin-bottom:10px}
.fc{flex:1}
.fl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--ink3);margin-bottom:5px}
/* RECIPE LINE */
.lrow{display:flex;gap:6px;align-items:flex-start;margin-bottom:9px}
.ipick{flex:1;position:relative}
.idisp{background:var(--bg);border:1.5px solid var(--border);border-radius:var(--r2);padding:9px 12px;cursor:pointer;min-height:42px;display:flex;flex-direction:column;justify-content:center;transition:border-color .15s}
.idisp.picked{border-color:rgba(61,139,114,.4);background:rgba(61,139,114,.04)}
.iname{font-size:12px;font-weight:700;color:var(--ink)}
.imeta{font-size:9px;color:var(--ink3);margin-top:1px}
.iph{font-size:12px;color:var(--ink4);font-style:italic}
.icost{font-size:9px;color:var(--teal);font-weight:700;margin-top:3px;padding-left:2px}
.dd{position:absolute;top:calc(100% + 4px);left:0;right:0;background:var(--white);border:1.5px solid var(--border2);border-radius:var(--r2);z-index:300;max-height:220px;overflow-y:auto;box-shadow:var(--shadow2)}
.dds{padding:8px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--white)}
.ddi{padding:10px 13px;cursor:pointer;border-bottom:1px solid rgba(0,0,0,.04);transition:background .1s}
.ddi:hover{background:var(--bg)}.ddi:active{background:var(--bg2)}
.ddn{font-size:12px;font-weight:700;color:var(--ink)}
.ddm{font-size:9px;color:var(--ink3);margin-top:1px}
.qu{display:flex;gap:4px;width:100px;flex-shrink:0}
.qi{width:50px;background:var(--bg);border:1.5px solid var(--border);border-radius:var(--r2);color:var(--ink);font-size:12px;padding:8px 4px;text-align:center;outline:none}
.qi:focus{border-color:var(--teal)}
.rm2{width:30px;height:30px;margin-top:6px;background:transparent;border:1.5px solid var(--border);border-radius:var(--r3);color:var(--ink3);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;transition:all .15s}
.rm2:active{border-color:var(--red);color:var(--red)}
/* SUMMARY */
.sum{background:var(--bg);border:1px solid var(--border);border-radius:var(--r2);padding:13px;margin-bottom:12px}
.sr{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border)}
.sr:last-child{border:none;padding-top:6px;margin-top:3px}
.sl{color:var(--ink2)}.sv{font-weight:700;color:var(--ink)}
.mbar{height:4px;border-radius:2px;background:var(--bg2);margin-top:8px;overflow:hidden}
.mf{height:100%;border-radius:2px;transition:width .4s}
/* GP BAR */
.gpbar{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:13px;display:flex;align-items:center;gap:12px;margin-bottom:12px;box-shadow:var(--shadow)}
/* RECIPE CARDS */
.rc{background:var(--white);border:1px solid var(--border);border-radius:var(--r);padding:14px;margin-bottom:10px;box-shadow:var(--shadow)}
.rn{font-size:16px;font-weight:800;color:var(--ink);margin-bottom:2px}
.rm3{font-size:10px;color:var(--ink3);margin-bottom:9px}
.rs{display:flex;gap:5px;flex-wrap:wrap}
.rst{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:10px;font-weight:600;color:var(--ink2)}
/* LIBRARY */
.li{display:flex;align-items:center;gap:10px;padding:11px 13px;background:var(--white);border:1px solid var(--border);border-radius:var(--r2);margin-bottom:6px;cursor:pointer;transition:border-color .15s;box-shadow:var(--shadow)}
.ldot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.linfo{flex:1;min-width:0}
.lname{font-size:13px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ldet{font-size:10px;color:var(--ink3);margin-top:1px}
.lcost{font-size:11px;font-weight:700;color:var(--teal);text-align:right;flex-shrink:0}
/* CAT TABS */
.cts{display:flex;gap:5px;overflow-x:auto;scrollbar-width:none;margin-bottom:12px;padding-bottom:2px}
.cts::-webkit-scrollbar{display:none}
.ct{flex-shrink:0;padding:6px 12px;border-radius:20px;font-size:10px;font-weight:700;cursor:pointer;border:1.5px solid var(--border);font-family:inherit;transition:all .12s;background:var(--white);color:var(--ink3);-webkit-tap-highlight-color:transparent}
.ct.on{color:white;border-color:transparent}
/* SUB TABS */
.stabs{display:flex;background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:3px;gap:3px;margin-bottom:16px;box-shadow:var(--shadow)}
.stab{flex:1;padding:7px 4px;border:none;border-radius:var(--r3);font-family:inherit;font-size:10px;font-weight:700;cursor:pointer;background:transparent;color:var(--ink3);transition:all .15s;text-align:center;-webkit-tap-highlight-color:transparent}
.stab.on{background:var(--teal);color:white}
/* STOCK */
.si{display:flex;align-items:center;gap:9px;padding:10px 12px;background:var(--white);border:1px solid var(--border);border-radius:var(--r2);margin-bottom:6px;box-shadow:var(--shadow)}
.si.counted{border-color:rgba(61,139,114,.3);background:rgba(61,139,114,.04)}
.sqi{width:54px;background:var(--bg);border:1.5px solid var(--border);border-radius:var(--r3);color:var(--ink);font-size:13px;padding:6px 4px;text-align:center;outline:none;-webkit-appearance:none}
.sqi:focus{border-color:var(--teal)}
/* INVOICE */
.dz{border:2px dashed var(--border2);border-radius:var(--r);padding:28px 16px;text-align:center;cursor:pointer;transition:all .2s;background:var(--bg)}
.dz.ov{border-color:var(--teal);background:rgba(61,139,114,.04)}
.th-row{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}
.th{width:60px;height:60px;border-radius:var(--r3);overflow:hidden;border:1px solid var(--border);cursor:pointer}
.th img{width:100%;height:100%;object-fit:cover}
.pw{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:11px 13px;margin:10px 0;display:flex;align-items:center;gap:10px;box-shadow:var(--shadow)}
.pb{flex:1;height:4px;background:var(--bg2);border-radius:2px;overflow:hidden}
.pbf{height:100%;background:linear-gradient(90deg,var(--teal),#4ade80);border-radius:2px;transition:width .5s}
.plbl{font-size:10px;color:var(--ink3);min-width:140px;text-align:right}
.itbl{width:100%;border-collapse:collapse;font-size:11px}
.itbl th{padding:5px 7px;text-align:left;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);background:var(--bg);border-bottom:1px solid var(--border)}
.itbl td{padding:5px 7px;border-bottom:1px solid var(--border)}
.mk{background:rgba(30,122,64,.1);color:var(--green);font-size:9px;padding:2px 6px;border-radius:3px;font-weight:700}
.mn{background:rgba(192,57,43,.08);color:var(--red);font-size:9px;padding:2px 6px;border-radius:3px}
/* LABOUR */
.ld{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:11px 13px;margin-bottom:7px;box-shadow:var(--shadow)}
.ldate{font-size:10px;color:var(--ink3);margin-bottom:7px;font-weight:600}
.lpills{display:flex;gap:5px;flex-wrap:wrap}
.lpill{border-radius:5px;padding:3px 9px;font-size:10px;font-weight:700;border:1px solid transparent}
/* STAFF CARD */
.scard{background:var(--white);border:1px solid var(--border);border-radius:var(--r);padding:14px;margin-bottom:10px;box-shadow:var(--shadow);transition:box-shadow .15s}
.scard:hover{box-shadow:var(--shadow2)}
.sav{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:16px;color:white;flex-shrink:0}
.sname{font-size:15px;font-weight:800;color:var(--ink)}
.srole{font-size:11px;font-weight:600;margin-top:1px}
.spills{display:flex;gap:5px;flex-wrap:wrap;margin-top:9px}
.spill{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:10px;font-weight:600;color:var(--ink2)}
/* ROSTER GRID */
.roster-grid{overflow-x:auto;margin-bottom:12px}
.rg-table{width:100%;border-collapse:separate;border-spacing:0;min-width:420px}
.rg-th{padding:8px 6px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--ink3);text-align:center;background:var(--bg);border-bottom:2px solid var(--border);position:sticky;top:0}
.rg-td{padding:4px;vertical-align:top;border-bottom:1px solid var(--border);min-width:52px}
.rg-name{padding:8px 10px;font-size:11px;font-weight:700;color:var(--ink);white-space:nowrap;border-bottom:1px solid var(--border);min-width:90px;background:var(--bg)}
.shift-chip{background:var(--teal);color:white;border-radius:6px;padding:3px 6px;font-size:9px;font-weight:700;margin-bottom:3px;display:flex;justify-content:space-between;align-items:center;gap:4px;line-height:1.3}
.shift-del{background:transparent;border:none;color:rgba(255,255,255,0.7);cursor:pointer;font-size:11px;padding:0;line-height:1}
.add-shift-btn{width:100%;background:var(--bg);border:1.5px dashed var(--border2);border-radius:6px;color:var(--ink4);font-size:10px;font-weight:600;padding:4px;cursor:pointer;font-family:inherit;transition:all .15s}
.add-shift-btn:hover{border-color:var(--teal);color:var(--teal)}
/* WEEK NAV */
.week-nav{display:flex;align-items:center;justify-content:space-between;background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:10px 14px;margin-bottom:12px;box-shadow:var(--shadow)}
.week-label{font-size:13px;font-weight:800;color:var(--ink)}
.week-btn{background:transparent;border:1px solid var(--border);border-radius:var(--r3);padding:5px 12px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:700;color:var(--ink2);transition:all .15s}
.week-btn:hover{border-color:var(--teal);color:var(--teal)}
/* DAY ACTIVITY BARS */
.day-bars{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:12px}
.day-bar-wrap{text-align:center}
.day-bar-outer{height:48px;background:var(--bg);border-radius:4px;display:flex;align-items:flex-end;overflow:hidden;margin-bottom:3px}
.day-bar-inner{width:100%;border-radius:4px;transition:height .4s}
.day-bar-lbl{font-size:8px;font-weight:700;color:var(--ink3)}
.day-bar-val{font-size:8px;color:var(--ink4)}
/* CONNECT APIS */
.api-row{display:flex;align-items:center;gap:12px;padding:12px;background:var(--white);border:1px solid var(--border);border-radius:var(--r2);margin-bottom:8px;box-shadow:var(--shadow)}
.api-icon{font-size:24px;flex-shrink:0}
.api-name{font-size:13px;font-weight:800;color:var(--ink)}
.api-desc{font-size:11px;color:var(--ink3);margin-top:1px}
.api-status-ok{font-size:9px;font-weight:700;color:var(--green);background:rgba(30,122,64,.1);border-radius:4px;padding:2px 8px}
/* AI */
.aibox{background:linear-gradient(135deg,rgba(91,110,240,.06),rgba(139,92,246,.06));border:1px solid rgba(139,92,246,.2);border-radius:var(--r);padding:13px;margin-top:10px}
.aititle{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#7c3aed;margin-bottom:8px}
.aitxt{font-size:12px;line-height:1.7;color:var(--ink2);white-space:pre-wrap}
/* ALERTS */
.alert{border-radius:var(--r2);padding:10px 13px;font-size:11px;margin-bottom:10px;font-weight:500}
.alert.ok{background:rgba(30,122,64,.07);border:1px solid rgba(30,122,64,.2);color:var(--green)}
.alert.warn{background:rgba(184,134,11,.07);border:1px solid rgba(184,134,11,.2);color:var(--amber)}
.alert.err{background:rgba(192,57,43,.07);border:1px solid rgba(192,57,43,.2);color:var(--red)}
.alert.info{background:rgba(44,111,168,.07);border:1px solid rgba(44,111,168,.2);color:var(--blue)}
/* TOAST */
.toast{position:fixed;top:16px;left:50%;transform:translateX(-50%);background:var(--teal);color:white;font-family:inherit;font-weight:700;font-size:13px;padding:10px 22px;border-radius:20px;z-index:9999;box-shadow:0 8px 24px rgba(0,0,0,.15);white-space:nowrap;animation:slideIn .25s ease}
/* MISC */
.spin{width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:white;border-radius:50%;animation:spin .7s linear infinite}
.spin-dark{width:13px;height:13px;border:2px solid rgba(0,0,0,.1);border-top-color:var(--teal);border-radius:50%;animation:spin .7s linear infinite}
.empty{text-align:center;padding:40px 16px;color:var(--ink3)}
.empty-ico{font-size:36px;margin-bottom:10px;opacity:.3}
.demo-tag{display:inline-flex;align-items:center;gap:6px;background:rgba(44,111,168,.08);border:1px solid rgba(44,111,168,.2);border-radius:6px;padding:4px 10px;font-size:10px;color:var(--blue);font-weight:700;margin-bottom:12px}
.chip{display:inline-flex;align-items:center;gap:4px;border-radius:5px;padding:2px 8px;font-size:10px;font-weight:700}
`;
// ── LINE ROW ──────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// LOGIN
// ─────────────────────────────────────────────────────────────────────────
if(!user) return (
<>
Kitchen Intelligence Platform
setAuthMode("login")}>Sign In
setAuthMode("register")}>Create Account
{authMode==="register"&&<>
>}
{aErr&&
{aErr}
}
{authMode==="login"?"Sign In →":"Create Account →"}
{authMode==="login"?"No account? ":"Already registered? "}
setAuthMode(authMode==="login"?"register":"login")}>{authMode==="login"?"Create one free":"Sign in"}
>
);
// ─────────────────────────────────────────────────────────────────────────
// MAIN
// ─────────────────────────────────────────────────────────────────────────
return (
<>
{toastS&&{toastS.msg}
}
{if(!e.target.closest('[data-dd]'))setActiveDD(null);}}>
{/* HEADER */}
{user.venue||"Lost Shore"}
Restaurant · Food Op Pro
{library.length} ingredients
{recipes.length} recipes
Sign out
{/* ══════ OVERVIEW ══════ */}
{tab==="dash"&&(
Overview
Weekly Performance
{isDemo&&
📊 Demo data — log 3+ trading days for live charts
}
Week Sales
{fmtGBP(wkSales)}
{dashData.reduce((a,d)=>a+d.covers,0)} covers
Gross Profit
{fmtGBP(wkGP)}
{((wkGP/wkSales)*100).toFixed(1)}% GP
Labour %
TARGET_LAB?"r":wkLab/wkSales>0.24?"a":"g"}`}>{(wkLab/wkSales*100).toFixed(1)}%
<{TARGET_LAB*100}% target
Food %
TARGET_FOOD?"r":wkFood/wkSales>0.25?"a":"g"}`}>{(wkFood/wkSales*100).toFixed(1)}%
<{TARGET_FOOD*100}% target
Combined
0.56?"r":(wkLab+wkFood)/wkSales>0.52?"a":"g"}`}>{((wkLab+wkFood)/wkSales*100).toFixed(1)}%
<56%
Sales vs Labour vs Food
`£${(v/1000).toFixed(1)}k`}/>
[`£${v.toLocaleString()}`,n]}/>
Covers per Day
[v,"Covers"]}/>
{labour.length>=3&&
Labour % vs {TARGET_LAB*100}% Target
({day:e.date.slice(5),lab:+(e.labourPct*100).toFixed(1),food:e.foodPct?+(e.foodPct*100).toFixed(1):null}))} margin={{top:4,right:4,left:-28,bottom:0}}>
`${v}%`}/>
[`${v}%`]}/>
{labour.some(e=>e.foodPct)&& }
}
{recipes.filter(r=>r.cos>TARGET_FOOD).length>0&&
⚠ Dishes Over {TARGET_FOOD*100}% Food Cost
{recipes.filter(r=>r.cos>TARGET_FOOD).map(r=>
{r.name} {(r.cos*100).toFixed(1)}% — raise to {fmtGBP(r.min)}
)}
}
{!labour.length&&
setTab("labour")}>Log first day → }
🔗 Connect Live Data
Link your POS and scheduling platform in the Staff tab for live data across the whole app.
setTab("staff")}>Go to Staff & Roster →
)}
{/* ══════ RECIPES ══════ */}
{tab==="recipe"&&(
{editId?"Edit Recipe":"Recipes"}
Cost it live · see GP instantly
{recipes.length>0&&
Saved ({recipes.length})
{recipes.map(r=>{const col=r.cos?cosCol(r.cos):"var(--ink3)",bg=r.cos?cosBg(r.cos):"transparent";return(
{r.name}
{r.section} · {new Date(r.savedAt).toLocaleDateString("en-GB")}
{fmtGBP(r.adj)} cost
{r.price>0&&
{fmtGBP(r.price)} menu
}
{r.cos!==null&&
{cosLbl(r.cos)}
}
{r.cos>TARGET_FOOD&&
Raise to {fmtGBP(r.min)} ex-VAT · {fmtGBP(r.sug)} inc VAT
}
{r.cos!==null&&
}
editRecipe(r)}>✏ Edit
{if(window.confirm("Delete?"))setRecipes(p=>p.filter(x=>x.id!==r.id));}}>✕ Delete
);})}
}
{editId?"Editing":"New Recipe"}
setRName(e.target.value)} style={{marginBottom:9}}/>
Section
setRSec(e.target.value)}>{["Brunch","Breakfast","All Day","Lost Deli","Lost Taco","Lost Flock","Quiz Night","Coffee Bar","Specials"].map(s=>{s} )}
{rc.adj>0&&rPrice&&
{rc.cos!==null?`${(rc.cos*100).toFixed(1)}% food cost`:fmtGBP(rc.adj)+" cost"}
{rc.cos!==null?`GP ${fmtGBP(rc.price-rc.adj)} · Min ${fmtGBP(rc.min)} ex-VAT`:"Set menu price to see GP%"}
{rc.cos!==null&&rc.cos>TARGET_FOOD&&
⚠ Raise to {fmtGBP(rc.min)} · {fmtGBP(rc.sug)} inc VAT
}
}
Ingredients
{rLines.map(l=>
)}
+ Add Ingredient
{rc.adj>0&&
Base cost {fmtGBP(rc.base)}
+{(rWaste*100).toFixed(0)}% waste {fmtGBP(rc.adj)}
{rc.price>0&&
GP per cover {fmtGBP(rc.price-rc.adj)}
}
{rc.cos!==null&&<>
COS / GP {(rc.cos*100).toFixed(1)}% / {((1-rc.cos)*100).toFixed(1)}%
}
l.ingId&&l.qty)}>💾 {editId?"Update":"Save"} Recipe
{editId&&{setEditId(null);setRName("");setRPrice("");setRLines([{id:uid(),ingId:null,qty:"",unit:"g"}]);}}>Cancel }
)}
{/* ══════ LIBRARY ══════ */}
{tab==="library"&&(
Library
{library.length} ingredients · prices flow into recipes
setIngForm({name:"",category:"Veg & Produce",packWt:"",packCost:"",supplier:"Central"})}>+ Add
{ingForm&&
{ingForm.id?"Edit":"New Ingredient"}
setIngForm(p=>({...p,name:e.target.value}))} style={{marginBottom:9}}/>
Category
setIngForm(p=>({...p,category:e.target.value}))}>{Object.keys(CAT_ICONS).map(c=>{c} )}
Supplier
setIngForm(p=>({...p,supplier:e.target.value}))}>{SUPPLIERS.map(s=>{s} )}
{ingForm.packWt&&ingForm.packCost&&
£{(parseFloat(ingForm.packCost)/parseFloat(ingForm.packWt)).toFixed(5)}/g · £{(parseFloat(ingForm.packCost)/parseFloat(ingForm.packWt)*1000).toFixed(2)}/kg
}
saveIng(ingForm)} disabled={!ingForm.name||!ingForm.packWt||!ingForm.packCost}>{ingForm.id?"Update":"Save"}
setIngForm(null)}>Cancel
{ingForm.id&&{if(window.confirm("Delete?"))delIng(ingForm.id);}}>Delete }
}
setLibQ(e.target.value)} style={{marginBottom:10}}/>
{["All",...Object.keys(CAT_ICONS)].map(c=>setLibCat(c)}>{c==="All"?"All":CAT_ICONS[c]+" "+c.split(" ")[0]} )}
{filtLib.map(i=>
setIngForm({...i})}>
{i.name}
{i.supplier} · {i.packWt}g · {fmtGBP(i.packCost)}/pack
{fmtGBP(i.costPerG*1000)}/kg
£{i.costPerG.toFixed(5)}/g
)}
)}
{/* ══════ INVOICES ══════ */}
{tab==="invoice"&&(
Invoices
AI reads · prices update · recipes recalculate
Upload Photos
e.preventDefault()} onDrop={e=>{e.preventDefault();addImgs(e.dataTransfer.files);}} onClick={()=>invRef.current?.click()}>
📄
Drop invoice photos here
Tap to choose · any supplier
addImgs(e.target.files)}/>
{invImgs.length>0&&
{invImgs.map(img=>
setInvImgs(p=>p.filter(i=>i.id!==img.id))}>
)}
}
{invScan?<> {invLbl||"Scanning…"}>:`🔍 Scan${invImgs.length>0?" "+invImgs.length+" photo":""}`}
{invImgs.length>0&&!invScan&&{setInvImgs([]);setInvExt(null);setInvErr("");}}>✕ }
{(invScan||invProg>0)&&
}
{invErr&&
⚠ {invErr}
}
{invExt&&
Extracted Items
Supplier
setInvExt(p=>({...p,meta:{...p.meta,supplier:e.target.value}}))}>{SUPPLIERS.map(s=>{s} )}
{invExt.items.filter(i=>i.libId).length>0&&
🔄 {invExt.items.filter(i=>i.libId).length} matched — prices + all recipe costs will update
}
i.selected)}>💾 Save + Update
{setInvExt(null);setInvErr("");}}>✕
}
{invoices.length>0&&
Saved Invoices
{invoices.slice(0,6).map(inv=>
{inv.supplier} · {inv.inv_number}
{inv.date} · {inv.items?.length} items · {fmtGBP(inv.total)}
{if(window.confirm("Delete?"))setInvoices(p=>p.filter(i=>i.id!==inv.id));}}>✕ )}
}
)}
{/* ══════ STOCK ══════ */}
{tab==="stock"&&(
Stock Take
Enter pack counts · live value
Stock Value
{fmtGBP(stockValue)}
{stockCounted.length} items counted
Progress
{stockCounted.length} / {library.length}
{stockCounted.length>0&&
Counted Items
{stockCounted.slice(0,5).map(i=>{const c=parseFloat(stock[i.id])||0;return
{i.name} {c} · {fmtGBP(c*i.packCost)}
;})}{stockCounted.length>5&&
+{stockCounted.length-5} more
}
Total {fmtGBP(stockValue)}
{if(window.confirm("Clear all?"))setStock({});}}>Clear All }
setStQ(e.target.value)} style={{marginBottom:10}}/>
{["All",...Object.keys(CAT_ICONS)].map(c=>setStCat(c)}>{c==="All"?"All":CAT_ICONS[c]+" "+c.split(" ")[0]} )}
{filtStock.map(i=>{const c=parseFloat(stock[i.id])||0;return
0?" counted":""}`}>
{i.name}
{fmtGBP(i.packCost)}/pack · {i.packWt}g
setStock(p=>({...p,[i.id]:e.target.value}))}/>
{c>0?fmtGBP(c*i.packCost):"—"}
;})}
)}
{/* ══════ LABOUR ══════ */}
{tab==="labour"&&(
Labour & Sales
Log daily trading · {TARGET_LAB*100}% target
{labStats&&<>
Sales ({labStats.n} days)
{fmtGBP(labStats.ts)}
Labour %
TARGET_LAB?"r":labStats.lp>0.24?"a":"g"}`}>{(labStats.lp*100).toFixed(1)}%
{fmtGBP(labStats.tl)} · <{TARGET_LAB*100}%
{labStats.fp!==null&&
Food %
TARGET_FOOD?"r":labStats.fp>0.25?"a":"g"}`}>{(labStats.fp*100).toFixed(1)}%
<{TARGET_FOOD*100}%
Combined %
0.56?"r":labStats.cp>0.52?"a":"g"}`}>{(labStats.cp*100).toFixed(1)}%
<56% target
}
>}
Log a Trading Day
{lSales&&lHours&&
Labour cost {fmtGBP(parseFloat(lHours)*parseFloat(lRate))}
Labour % TARGET_LAB?"var(--red)":"var(--green)"}}>{(parseFloat(lHours)*parseFloat(lRate)/parseFloat(lSales)*100).toFixed(1)}%
{lFood&&
Food % {(parseFloat(lFood)/parseFloat(lSales)*100).toFixed(1)}%
}
}
➕ Log Trading Day
AI Dish Suggestions
Claude analyses your {library.length} ingredients and suggests high-margin dishes.
{aiLoad?<> Analysing…>:"✨ Suggest profitable dishes"}
{aiTxt&&
💡 Claude's suggestions
{aiTxt}
}
{labour.length>0&&
Recent Days
{labour.slice(0,10).map(e=>
{e.date}{e.note&&· {e.note} }
{fmtGBP(e.sales)} sales
TARGET_LAB?"rgba(192,57,43,.08)":"rgba(44,111,168,.08)",color:e.labourPct>TARGET_LAB?"var(--red)":"var(--blue)",borderColor:e.labourPct>TARGET_LAB?"rgba(192,57,43,.2)":"rgba(44,111,168,.2)"}}>{(e.labourPct*100).toFixed(1)}% labour
{e.foodPct!==null&&
{(e.foodPct*100).toFixed(1)}% food
}
0.56?"rgba(192,57,43,.08)":"rgba(30,122,64,.08)",color:e.combinedPct>0.56?"var(--red)":"var(--green)",borderColor:e.combinedPct>0.56?"rgba(192,57,43,.2)":"rgba(30,122,64,.2)"}}>{(e.combinedPct*100).toFixed(1)}% combined
)}
{if(window.confirm("Clear all?"))setLabour([]);}}>Clear all }
)}
{/* ══════ STAFF & ROSTER ══════ */}
{tab==="staff"&&(
Staff & Roster
Smart rostering · {TARGET_LAB*100}% labour target
{/* Sub-tabs */}
{[{id:"roster",label:"📅 Roster"},{id:"team",label:"👥 Team"},{id:"analytics",label:"📊 Analytics"},{id:"connect",label:"🔗 Connect"}].map(t=>(
setStaffView(t.id)}>{t.label}
))}
{/* ── ROSTER ── */}
{staffView==="roster"&&(
<>
{/* Week navigation */}
changeWeek(-1)}>← Prev
w/c {new Date(rosterWeek).toLocaleDateString("en-GB",{day:"numeric",month:"short"})}
{staff.length} staff · {fmtGBP(rosterCost)} labour
changeWeek(1)}>Next →
{/* Labour budget vs cost */}
{estWeekSales>0&&(
TARGET_LAB?"rgba(192,57,43,.3)":"rgba(61,139,114,.2)",background:rosterLabPct!==null&&rosterLabPct>TARGET_LAB?"rgba(192,57,43,.04)":"rgba(61,139,114,.04)"}}>
{rosterLabPct!==null?`${(rosterLabPct*100).toFixed(1)}% est. labour`:"Add shifts to see labour %"}
Budget: {fmtGBP(estWeekSales*TARGET_LAB)} · Rostered: {fmtGBP(rosterCost)}
Based on {DAYS.filter(d=>dayAverages[d]?.n>0).length} days of historical data
{labour.length<3&&" (add trading days for accuracy)"}
)}
{/* Day activity from POS history */}
{labour.length>0&&(
Historical Sales by Day
{DAYS.map(d=>{
const avg=dayAverages[d]?.avg||0;
const maxAvg=Math.max(...DAYS.map(x=>dayAverages[x]?.avg||0));
const pct=maxAvg>0?avg/maxAvg:0;
const dayCost=rosterDayCost[d]||0;
const labPct=avg>0?dayCost/avg:0;
return(
TARGET_LAB?"var(--red)":pct>0.7?"var(--teal)":pct>0.4?"#7bc4b0":"var(--bg2)"}}/>
{d}
{avg>0?`£${(avg/1000).toFixed(1)}k`:"—"}
);
})}
Taller = busier · Red = labour over {TARGET_LAB*100}% for that day
)}
{/* Roster grid */}
{staff.length===0?(
👥
No staff added yet
Add your team in the Team tab first
setStaffView("team")}>Add Team Members →
):(
<>
Staff
{DAYS.map(d=>(
{d}
{rosterDayCost[d]>0&&{fmtGBP(rosterDayCost[d])}
}
))}
Hrs
{staff.map(s=>(
{initials(s.name)}
{s.name.split(" ")[0]}
{fmtGBP(s.rate)}/hr
{DAYS.map(d=>{
const shifts=(weekRoster[s.id]||[]).filter(sh=>sh.day===d);
return(
{shifts.map(sh=>(
{sh.start}–{sh.end}
removeShift(s.id,sh.id)}>×
))}
setShiftForm({staffId:s.id,staffName:s.name,day:d,start:"09:00",end:"17:00"})}>+
);
})}
s.contractHours?"var(--red)":"var(--ink)"}}>
{(staffHours[s.id]||0).toFixed(0)}h
{s.contractHours>0&&{s.contractHours}h ctr
}
))}
{/* AI roster builder */}
✨ AI Roster Builder
Claude analyses your busiest days from {labour.length} logged trading days and builds the most cost-efficient roster to keep labour at {TARGET_LAB*100}%.
{aiRosterLoad?<> Building optimal roster…>:"✨ Build AI Roster Suggestion"}
{aiRosterTxt&&
🗓 AI Roster Recommendation
{aiRosterTxt}
}
>
)}
{/* Shift form modal */}
{shiftForm&&(
{if(e.target===e.currentTarget)setShiftForm(null);}}>
Add Shift
{shiftForm.staffName} · {shiftForm.day}
{shiftForm.start&&shiftForm.end&&(()=>{
const s=staff.find(x=>x.id===shiftForm.staffId);
const hrs=shiftHrs({start:shiftForm.start,end:shiftForm.end});
return hrs>0&&
{hrs.toFixed(1)}hrs · {fmtGBP(hrs*(s?.rate||12.50))} cost
;
})()}
addShift(shiftForm.staffId,{day:shiftForm.day,start:shiftForm.start,end:shiftForm.end})}>Add Shift ✓
setShiftForm(null)}>Cancel
)}
>
)}
{/* ── TEAM ── */}
{staffView==="team"&&(
<>
{staff.length} team members
setStaffForm({name:"",role:"Chef de Partie",contract:"Full Time",rate:"12.50",contractHours:"40",email:"",phone:""})}>+ Add Member
{staffForm&&(
{staffForm.id?"Edit":"New Team Member"}
Role
setStaffForm(p=>({...p,role:e.target.value}))}>{ROLES.map(r=>{r} )}
Contract
setStaffForm(p=>({...p,contract:e.target.value}))}>{CONTRACT.map(c=>{c} )}
Weekly Cost (est.)
{fmtGBP((parseFloat(staffForm.rate)||0)*(parseFloat(staffForm.contractHours)||0))}
saveStaff(staffForm)} disabled={!staffForm.name||!staffForm.role}>{staffForm.id?"Update":"Add to Team"}
setStaffForm(null)}>Cancel
{staffForm.id&&{if(window.confirm("Remove?"))delStaff(staffForm.id);}}>Remove }
)}
{staff.length===0?(
👤
No team members yet
Add your team above or connect a scheduling platform in the Connect tab
):(
<>
{/* Weekly wage bill summary */}
Weekly Wage Bill (contracted hours)
{fmtGBP(staff.reduce((a,s)=>a+s.rate*s.contractHours,0))}
{staff.reduce((a,s)=>a+s.contractHours,0).toFixed(0)} total contracted hrs · {staff.length} staff
{estWeekSales>0&&
a+s.rate*s.contractHours,0)/estWeekSales>TARGET_LAB?"var(--red)":"var(--green)"}}>
{(staff.reduce((a,s)=>a+s.rate*s.contractHours,0)/estWeekSales*100).toFixed(1)}%
of est. sales ({TARGET_LAB*100}% target)
}
{staff.map(s=>(
{initials(s.name)}
{s.name}
{s.role}
{s.email&&
✉ {s.email}
}
{s.contract}
{s.contractHours}h/wk
{fmtGBP(s.rate)}/hr
{fmtGBP(s.rate*s.contractHours)}/wk
{staffHours[s.id]>0&&
{(staffHours[s.id]||0).toFixed(0)}h rostered
}
setStaffForm({...s})}>✏ Edit
))}
>
)}
>
)}
{/* ── ANALYTICS ── */}
{staffView==="analytics"&&(
<>
Labour % vs {TARGET_LAB*100}% Target — All Time
{labour.length>=3?(
({day:e.date.slice(5),lab:+(e.labourPct*100).toFixed(1),food:e.foodPct?+(e.foodPct*100).toFixed(1):null,combined:+(e.combinedPct*100).toFixed(1)}))} margin={{top:4,right:4,left:-28,bottom:0}}>
`${v}%`}/>
[`${v}%`]}/>
{labour.some(e=>e.foodPct)&& }
):
Log at least 3 trading days to see trends
}
Busiest Days (Historical)
({day:d,sales:Math.round(dayAverages[d]?.avg||0),n:dayAverages[d]?.n||0}))} margin={{top:4,right:4,left:-28,bottom:0}}>
`£${(v/1000).toFixed(1)}k`}/>
[`£${v.toLocaleString()} avg (${p?.payload?.n} days)`,n]}/>
Based on {labour.length} logged trading days. Add more days for accuracy.
{/* Staff cost breakdown */}
{staff.length>0&&(
Staff Cost Breakdown
{staff.sort((a,b)=>b.rate*b.contractHours-a.rate*a.contractHours).map(s=>{
const wkCost=s.rate*s.contractHours, pct=staff.reduce((a,x)=>a+x.rate*x.contractHours,0)>0?wkCost/staff.reduce((a,x)=>a+x.rate*x.contractHours,0):0;
return(
{s.name} ({s.role})
{fmtGBP(wkCost)}/wk
{fmtGBP(s.rate)}/hr · {s.contractHours}h · {(pct*100).toFixed(0)}% of wage bill
);
})}
Total weekly wage
{fmtGBP(staff.reduce((a,s)=>a+s.rate*s.contractHours,0))}
{estWeekSales>0&&
Labour budget at {TARGET_LAB*100}%: {fmtGBP(estWeekSales*TARGET_LAB)} · {staff.reduce((a,s)=>a+s.rate*s.contractHours,0)>estWeekSales*TARGET_LAB?⚠ Over by {fmtGBP(staff.reduce((a,s)=>a+s.rate*s.contractHours,0)-estWeekSales*TARGET_LAB)} :✅ Under by {fmtGBP(estWeekSales*TARGET_LAB-staff.reduce((a,s)=>a+s.rate*s.contractHours,0))} }
}
)}
>
)}
{/* ── CONNECT ── */}
{staffView==="connect"&&(
<>
Connect any platform — use the pre-built integrations below or add your own custom API endpoint from any system.
{/* ── PRE-BUILT: Scheduling ── */}
Scheduling Platforms
{[{id:"sched",icon:"📅",name:"Deputy",desc:"Import staff, rosters and actual hours worked automatically"},{id:"sched",icon:"🔄",name:"Rotaready",desc:"Sync staff profiles, shifts and wage data in real time"},{id:"sched",icon:"👥",name:"Bizimply",desc:"Pull rota, attendance and payroll data automatically"}].map((api,i)=>(
{api.icon}
{schedConnected?
✓ Connected :(
connectApi("sched")} disabled={connectingApi==="sched"}>
{connectingApi==="sched"? :"Connect"}
)}
))}
{/* ── PRE-BUILT: POS ── */}
POS Systems
{[{id:"pos",icon:"💳",name:"Square",desc:"Pull daily takings, hourly sales breakdowns and cover counts"},{id:"pos",icon:"🍽",name:"Lightspeed",desc:"Live sales by hour to identify peak times for rostering"},{id:"pos",icon:"📱",name:"iZettle / Zettle",desc:"Import daily sales totals automatically"},{id:"pos",icon:"📊",name:"NOQ",desc:"Pull sales and cover data for busy period analysis"}].map((api,i)=>(
{api.icon}
{posConnected?
✓ Connected :(
connectApi("pos")} disabled={connectingApi==="pos"}>
{connectingApi==="pos"? :"Connect"}
)}
))}
{/* ── OPEN / CUSTOM API ── */}
Custom API Connection
Open
Connect any system with an API — your own POS, payroll, ERP, booking system or any third-party platform. Paste in the endpoint URL, add your API key, and Food Op Pro pulls the data automatically.
{["Any POS","Payroll","Booking systems","ERP","HR platforms","Custom apps","Webhooks"].map(tag=>(
{tag}
))}
setCustomApiForm({name:"",url:"",method:"GET",keyHeader:"Authorization",key:"",dataType:"sales",notes:"",enabled:true})}>
+ Add Custom API
{/* Custom API form */}
{customApiForm&&(
{customApiForm.id?"Edit API Connection":"New API Connection"}
Data Type
setCustomApiForm(p=>({...p,dataType:e.target.value}))}>
Sales / Takings
Staff / Roster
Labour Hours
Stock / Inventory
Orders / Covers
Payroll
Custom / Other
API Endpoint URL
setCustomApiForm(p=>({...p,url:e.target.value}))}/>
The full URL that returns your data as JSON
HTTP Method
setCustomApiForm(p=>({...p,method:e.target.value}))}>
GET POST
API Key / Token
setCustomApiForm(p=>({...p,key:e.target.value}))}/>
Stored securely on this device only — never sent to our servers
saveCustomApi(customApiForm)} disabled={!customApiForm.name||!customApiForm.url}>{customApiForm.id?"Update":"Save Connection"}
setCustomApiForm(null)}>Cancel
{customApiForm.id&&{if(window.confirm("Remove?"))delCustomApi(customApiForm.id);}}>Remove }
)}
{/* Saved custom APIs */}
{customApis.length>0&&(
<>
Your Custom Connections ({customApis.length})
{customApis.map(api=>{
const tr=testResult[api.id];
const statusColor=api.status==="connected"?"var(--green)":api.status==="error"?"var(--red)":"var(--ink3)";
const statusLabel=api.status==="connected"?"✓ Connected":api.status==="error"?"✗ Error":"◦ Untested";
return(
{api.dataType==="sales"?"💳":api.dataType==="staff"?"👥":api.dataType==="labour"?"⏱":api.dataType==="stock"?"📦":api.dataType==="orders"?"🍽":api.dataType==="payroll"?"💰":"🔗"}
{api.name}
{statusLabel}
{api.url}
{api.method}
{api.dataType}
{api.key&&🔑 Auth set }
{api.lastTested&&Tested {new Date(api.lastTested).toLocaleDateString("en-GB")} }
{api.notes&&
{api.notes}
}
{/* Test result preview */}
{tr&&(
HTTP {tr.status} — {tr.ok?"Success":"Failed"}
{tr.preview}
)}
testCustomApi(api)} disabled={testingApi===api.id}>
{testingApi===api.id?<> Testing…>:"🔍 Test Connection"}
setCustomApiForm({...api})}>✏ Edit
{if(window.confirm("Remove?"))delCustomApi(api.id);}}>✕
);
})}
>
)}
{/* How it works */}
How Connections Work
{[
["📅","Scheduling API → Roster tab","Staff, shift times and contracted hours import automatically into the weekly roster"],
["💳","POS API → Labour & Overview tabs","Daily sales and cover counts populate the labour tracker and dashboard charts without manual entry"],
["⏱","Actual hours → Labour % auto-calculation","Real shift hours × rate ÷ actual sales = live labour % with no logging required"],
["🤖","AI uses all data for roster suggestions","The more data connected, the smarter the AI roster builder gets at predicting busy periods"],
["🔑","Your keys stay on your device","API keys are stored in your browser only — they never leave your device or touch our servers"]
].map(([icon,title,desc],i,arr)=>(
))}
>
)}
)}
{/* BOTTOM NAV */}
{TABS.map(({id,icon,label})=>(
setTab(id)}>
{icon} {label}
))}
>
);
}