import React, { useState, useEffect, useCallback } from "react"; import { Sun, Moon, Check, ClipboardList, MessageSquarePlus, MapPin, ChevronRight, AlertTriangle, RotateCcw, History, X, User, Lock, Megaphone, ClipboardCheck, Pin, LogOut, ShieldCheck, BookOpen, Beer, FileText, Search, AlertCircle, Plus, Trash2, Eye } from "lucide-react"; // --------------------------------------------------------------------------- // Taproom Shift Manager — phone-first checklist + shift-handoff tool // Built for ~15 part-time bartenders across two taproom locations. // Includes a password-gated Staff Access area for managers: // • post pre-shift notes bartenders see on the Open screen // • review submitted closing-checklist reports // All data lives in the app's persistent storage. // --------------------------------------------------------------------------- const LOCATIONS = ["Main Taproom", "Second Location"]; // Manager passcode. Change this to whatever you want — single shared code. const MANAGER_CODE = "brew2025"; // Checklist templates. Edit these lists to match your real SOPs — each item // is just a line of text. Grouped into sections for scannability on a phone. const OPEN_LIST = [ { section: "Opening Duties", items: [ "Familiarize yourself with the current menu (and what we're out of)", "Walk through taproom, brewery, patio — tables clean, everything looks good", "Lights and music on, check volume level inside and out", "Make sure enough garnishes are prepared and bar is stocked as much as possible", ]}, ]; // Closing list is the summer version for now. A Winter variant can be added // later (swap the patio/umbrella/cushion steps). See CLOSE_LIST_LABEL below. const CLOSE_LIST_LABEL = "Closing checklist"; const CLOSE_LIST = [ { section: "Floor & Tables", items: [ "Wipe all tables, inside and out", "Dry mop / sweep taproom — check under tables for crumbs, check patio for trash", "Push in chairs, reset moved tables and chairs on patio, close umbrellas", "Put away cushions, make sure heat lamps are off", ]}, { section: "Bar & Draft", items: [ "Spray and wipe all bar surfaces and crowler machine (if used)", "Use squirt bottle to rinse taps and put in stoppers", "Remove drip tray cover, wipe both sides, rinse and wipe tray, put cover over drain", "Wash bar mats and leave flat to dry", "Dishwasher: remove plug, drain, turn off", ]}, { section: "Glassware & Restock", items: [ "Do all dishes and put clean glasses away", "Restock to-go fridge & bar fridge as much as possible", "Refill / cut garnishes for tomorrow", ]}, { section: "Trash & Equipment", items: [ "Dump recycling bins in blue bin on loading dock, return to taproom", "Put trash on loading dock and put in new bag", "Plug in handhelds", ]}, { section: "Cash", items: [ "Count cash and \u201Cend drawer\u201D in system", ]}, { section: "Lock Up", items: [ "Close and lock loading dock side door, garage door, and warehouse door", "Set both thermostats to \u201CAway\u201D or turn off", "Turn off music (pause Spotify) — pause bathroom music by light switches", "Flip black power switch in warehouse to turn off speakers and patio lights", "Turn off taproom lights (leave \u201Cback bar\u201D light on low)", "Close taproom doors to brewery and warehouse", "Clock out", "Lock front door, back door — make sure exiting door is locked behind you", ]}, ]; // Seed content — example beer sheets and SOPs so the binder isn't empty. // You'll replace these via Staff Access. Beers/SOPs are shared across both // locations; SOP acknowledgements are tracked per person. const SEED_BEERS = [ { id: "b1", name: "Mainstay IPA", style: "West Coast IPA", abv: "6.8%", ibu: "65", desc: "Flagship IPA. Citrus and pine, Centennial + Simcoe hops, dry finish. Our best seller — lead with this for hop-forward guests. Pairs with spicy food and burgers." }, { id: "b2", name: "Golden Hour", style: "Kölsch", abv: "4.9%", ibu: "22", desc: "Crisp, light, easy. The gateway beer for guests who 'don't like craft beer.' Clean, slightly fruity, crackery malt. Great on the patio." }, { id: "b3", name: "Night Shift", style: "Oatmeal Stout", abv: "5.6%", ibu: "30", desc: "Smooth, roasty, notes of coffee and chocolate. Not heavy despite the dark color — tell guests that. Pairs with dessert." }, ]; const SEED_SOPS = [ { id: "s1", title: "Pouring a proper pour", body: "Open the tap fully and quickly — never pour with a partly-open faucet (causes foam). Hold the glass at 45°, straighten as it fills, aim for one to one-and-a-half fingers of head. If a line pours foamy, note it in the Shift Log so we can check the line.", posted: "Example SOP" }, ]; async function safeGet(key) { try { const r = await window.storage.get(key); return r ? r.value : null; } catch { return null; } } async function safeSet(key, value) { try { await window.storage.set(key, value); } catch { /* no-op */ } } const todayKey = () => new Date().toISOString().slice(0, 10); export default function App() { const [location, setLocation] = useState(LOCATIONS[0]); const [tab, setTab] = useState("open"); // open | close | log const [name, setName] = useState(""); const [openState, setOpenState] = useState({}); const [closeState, setCloseState] = useState({}); const [logEntries, setLogEntries] = useState([]); const [history, setHistory] = useState([]); // closing reports const [shiftNote, setShiftNote] = useState(""); // manager pre-shift note const [loaded, setLoaded] = useState(false); const [draft, setDraft] = useState(""); const [flag, setFlag] = useState(false); // manager area const [showStaff, setShowStaff] = useState(false); const [unlocked, setUnlocked] = useState(false); const [codeInput, setCodeInput] = useState(""); const [codeErr, setCodeErr] = useState(false); const [noteDraft, setNoteDraft] = useState(""); const [mgrTab, setMgrTab] = useState("notes"); // notes | reports const [savedFlash, setSavedFlash] = useState(false); // quality attestation gate (closing only) const [attestOpen, setAttestOpen] = useState(false); const [attestName, setAttestName] = useState(""); const [attestErr, setAttestErr] = useState(false); // binder: beer sheets + SOPs + per-person acknowledgements const [beers, setBeers] = useState([]); const [sops, setSops] = useState([]); const [acks, setAcks] = useState({}); // { sopId: [names] } const [binderView, setBinderView] = useState("beers"); // beers | sops const [search, setSearch] = useState(""); const [openSheet, setOpenSheet] = useState(null); // beer or sop being read const [readSopId, setReadSopId] = useState(null); // sop open in read-modal // manager binder editors const [beerForm, setBeerForm] = useState(null); // {id?,name,style,abv,ibu,desc} const [sopForm, setSopForm] = useState(null); // {id?,title,body} const dayPrefix = `${location}|${todayKey()}`; // load persisted state when location changes useEffect(() => { let active = true; (async () => { setLoaded(false); const o = await safeGet(`open|${dayPrefix}`); const c = await safeGet(`close|${dayPrefix}`); const l = await safeGet(`log|${location}`); const h = await safeGet(`reports|${location}`); const sn = await safeGet(`shiftnote|${location}`); const nm = await safeGet("bartender-name"); const bz = await safeGet("binder-beers"); const sp = await safeGet("binder-sops"); const ak = await safeGet("binder-acks"); if (!active) return; setOpenState(o ? JSON.parse(o) : {}); setCloseState(c ? JSON.parse(c) : {}); setLogEntries(l ? JSON.parse(l) : []); setHistory(h ? JSON.parse(h) : []); setShiftNote(sn || ""); setNoteDraft(sn || ""); setBeers(bz ? JSON.parse(bz) : SEED_BEERS); setSops(sp ? JSON.parse(sp) : SEED_SOPS); setAcks(ak ? JSON.parse(ak) : {}); if (nm) setName(nm); setLoaded(true); })(); return () => { active = false; }; }, [location]); // eslint-disable-line const toggle = useCallback((which, id) => { if (which === "open") { setOpenState(prev => { const next = { ...prev, [id]: !prev[id] }; safeSet(`open|${dayPrefix}`, JSON.stringify(next)); return next; }); } else { setCloseState(prev => { const next = { ...prev, [id]: !prev[id] }; safeSet(`close|${dayPrefix}`, JSON.stringify(next)); return next; }); } }, [dayPrefix]); const saveName = (v) => { setName(v); safeSet("bartender-name", v); }; const addLog = () => { if (!draft.trim()) return; const entry = { id: Date.now(), text: draft.trim(), flag, by: name || "Unnamed", when: new Date().toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }), }; const next = [entry, ...logEntries].slice(0, 60); setLogEntries(next); safeSet(`log|${location}`, JSON.stringify(next)); setDraft(""); setFlag(false); }; const removeLog = (id) => { const next = logEntries.filter(e => e.id !== id); setLogEntries(next); safeSet(`log|${location}`, JSON.stringify(next)); }; const currentList = tab === "open" ? OPEN_LIST : CLOSE_LIST; const currentState = tab === "open" ? openState : closeState; const totalItems = currentList.reduce((n, s) => n + s.items.length, 0); const doneItems = currentList.reduce( (n, s) => n + s.items.filter((_, i) => currentState[`${s.section}-${i}`]).length, 0 ); const pct = totalItems ? Math.round((doneItems / totalItems) * 100) : 0; const complete = doneItems === totalItems && totalItems > 0; // capture full detail when a shift is finished const finishShift = (attestedName) => { const flagged = logEntries.filter(e => e.flag).map(e => e.text); const record = { id: Date.now(), type: tab, location, by: name || "Unnamed", date: new Date().toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }), items: `${doneItems}/${totalItems}`, sections: currentList.map(s => ({ section: s.section, items: s.items.map((label, i) => ({ label, done: !!currentState[`${s.section}-${i}`] })), })), flaggedNotes: flagged, attestedBy: attestedName || null, }; const next = [record, ...history].slice(0, 60); setHistory(next); safeSet(`reports|${location}`, JSON.stringify(next)); if (tab === "open") { setOpenState({}); safeSet(`open|${dayPrefix}`, JSON.stringify({})); } else { setCloseState({}); safeSet(`close|${dayPrefix}`, JSON.stringify({})); } }; // Open submits directly; Close goes through the quality attestation gate. const onSubmit = () => { if (tab === "close") { setAttestName(name || ""); setAttestErr(false); setAttestOpen(true); } else finishShift(null); }; const confirmAttest = () => { if (!attestName.trim()) { setAttestErr(true); return; } finishShift(attestName.trim()); setAttestOpen(false); setAttestName(""); setAttestErr(false); }; // ---- binder actions ---- const persistBeers = (next) => { setBeers(next); safeSet("binder-beers", JSON.stringify(next)); }; const persistSops = (next) => { setSops(next); safeSet("binder-sops", JSON.stringify(next)); }; const persistAcks = (next) => { setAcks(next); safeSet("binder-acks", JSON.stringify(next)); }; // bartender acknowledges an SOP (records their name once) const acknowledgeSop = (sopId) => { const who = (name || "").trim(); if (!who) return; // require a name in the header field const list = acks[sopId] || []; if (!list.includes(who)) persistAcks({ ...acks, [sopId]: [...list, who] }); setReadSopId(null); }; const hasRead = (sopId) => { const who = (name || "").trim(); return who ? (acks[sopId] || []).includes(who) : false; }; const unreadSops = sops.filter(s => !hasRead(s.id)); // manager: save/delete beer sheets const saveBeer = () => { if (!beerForm.name.trim()) return; const next = beerForm.id ? beers.map(b => b.id === beerForm.id ? beerForm : b) : [...beers, { ...beerForm, id: "b" + Date.now() }]; persistBeers(next); setBeerForm(null); }; const deleteBeer = (id) => persistBeers(beers.filter(b => b.id !== id)); // manager: save/delete SOPs. New SOP starts unacknowledged by everyone. const saveSop = () => { if (!sopForm.title.trim()) return; let next, newId = sopForm.id; if (sopForm.id) { next = sops.map(s => s.id === sopForm.id ? { ...s, title: sopForm.title, body: sopForm.body } : s); } else { newId = "s" + Date.now(); next = [{ id: newId, title: sopForm.title, body: sopForm.body, posted: new Date().toLocaleDateString() }, ...sops]; } persistSops(next); setSopForm(null); }; const deleteSop = (id) => { persistSops(sops.filter(s => s.id !== id)); const { [id]: _drop, ...rest } = acks; persistAcks(rest); }; const resetList = () => { if (tab === "open") { setOpenState({}); safeSet(`open|${dayPrefix}`, JSON.stringify({})); } else { setCloseState({}); safeSet(`close|${dayPrefix}`, JSON.stringify({})); } }; // ---- manager actions ---- const tryUnlock = () => { if (codeInput.trim() === MANAGER_CODE) { setUnlocked(true); setCodeErr(false); setCodeInput(""); } else { setCodeErr(true); } }; const saveNote = () => { setShiftNote(noteDraft); safeSet(`shiftnote|${location}`, noteDraft); setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1800); }; const clearNote = () => { setShiftNote(""); setNoteDraft(""); safeSet(`shiftnote|${location}`, ""); }; const closeStaff = () => { setShowStaff(false); setUnlocked(false); setCodeInput(""); setCodeErr(false); }; const closingReports = history.filter(h => h.type === "close"); return (
{/* ---- Header ---- */}
SHIFT MANAGER
Taproom Operations
{LOCATIONS.map(loc => ( ))}
saveName(e.target.value)} />
{/* ---- Tabs ---- */}
{!loaded &&
Loading shift…
} {loaded && tab !== "log" && ( <> {/* manager pre-shift note shows on the OPEN screen */} {tab === "open" && shiftNote && (
Note from management — read before your shift
{shiftNote}
)}
{tab === "open" ? "Opening checklist" : CLOSE_LIST_LABEL} {pct}%
{doneItems} of {totalItems} done
{currentList.map(sec => (

{sec.section}

{sec.items.map((item, i) => { const id = `${sec.section}-${i}`; const on = !!currentState[id]; return ( ); })}
))}
)} {loaded && tab === "log" && ( <>
Pass anything the next shift needs to know — a keg that blew, a broken stool, a regular's tab, a delivery coming in.