HOSPITALITY HUB Choose your workspace

Inventory & Ordering

Sign in to view your restaurant inventory.

HUB ONLINELOCAL TIMEOPERATIONS · 3 LOCATIONS
Great work. Another one in the books ✨
HALCYON HOSPITALITY HUB

Employee portal

Log in or create an account with your email and password. Registered users can explore all three restaurant workspaces.

Demo access: all registered users can enter the prototype. Saved orders and invoices remain on this browser only and are not synchronized with other employees. Do not enter sensitive customer or payment details.
Working on:
HALCYON · OWNER CONTROL

Customize your Hub

Owner tools are reserved for your verified email. These design settings currently affect only this browser; staff-wide cloud settings come with the Supabase migration.


Assistant · Workflow Preview

Try “create a quote,” “new invoice,” “add catering booking,” “open CRM,” or “edit menu.” I’ll open the relevant workflow, but won’t save, send, or charge without your confirmation. This is a command helper, not yet a generative AI model.

Daily Operations

Quick actions and checklists. Data remains saved in this browser.

Quick actions

Routine checklist

Due today / overdue

+Number(n||0).toFixed(2); function session(){try{return JSON.parse(sessionStorage.getItem('hh_supabase_session_v1')||'null')}catch{return null}} async function request(path,opt){const s=session();if(!s?.access_token)throw Error('Please log in again');const r=await fetch(URL+path,{method:opt?.method||'GET',headers:{apikey:KEY,Authorization:'Bearer '+s.access_token,'Content-Type':'application/json',Prefer:'return=representation',...opt?.headers},body:opt?.body?JSON.stringify(opt.body):undefined});const text=await r.text();let data;try{data=JSON.parse(text)}catch{data=text}if(!r.ok)throw Error(data?.message||data?.hint||'Request failed');return data} async function load(){const s=session();if(!s?.access_token){content.innerHTML='

Please log in to use Inventory.

';return}try{const r=await fetch(AUTH+'/user',{headers:{apikey:KEY,Authorization:'Bearer '+s.access_token}});if(!r.ok)throw Error('Session expired: log in again');user=await r.json();access=await request('/inventory_access?select=user_id,location_id,role&user_id=eq.'+encodeURIComponent(user.id));if(!access.length){content.innerHTML='

Inventory access not assigned

Your account is registered, but an owner must assign you to a restaurant.

Your employee ID: '+esc(user.id)+'

';return}const all=await Promise.all([request('/locations?select=id,name'),request('/inventory_items?select=*&order=name.asc'),request('/inventory_counts?select=*&order=counted_at.desc&limit=300'),request('/inventory_orders?select=*&order=created_at.desc&limit=200')]);locations=all[0];items=all[1];counts=all[2];orders=all[3];if(!current||!access.some(a=>a.location_id===current))current=access[0].location_id;draw()}catch(e){content.innerHTML='
'+esc(e.message)+'
'}} function role(){return access.find(a=>a.location_id===current)?.role||'viewer'} function loc(){return locations.find(l=>l.id===current)?.name||'Restaurant'} function canChange(){return ['owner','manager'].includes(role())} function canCount(){return canChange()||role()==='counter'} function latestCount(id){return counts.find(c=>c.item_id===id)} function qty(x){const c=latestCount(x.id);return c?Number(c.counted_units):Number(x.on_hand_units||0)} function round(x){return Number(x).toFixed(2)} function messageText(s){message.textContent=s;setTimeout(()=>{if(message.textContent===s)message.textContent=''},5500)} function table(){const list=items.filter(i=>i.location_id===current);return '

Products · '+esc(loc())+'

'+list.map(i=>'').join('')+'
ProductCategoryBottle sizeOn handParCost / bottleVendorAction
'+esc(i.name)+''+esc(i.category)+''+esc(i.size_ml)+' mL'+round(qty(i))+' bottles'+round(i.par_units)+''+fmt(i.unit_cost)+''+esc(i.vendor)+''+(canCount()?' ':'')+(canChange()?'':'')+'
'+(list.length?'':'

No products yet. Add your first bottle to begin.

')+'
'} function draw(){const list=items.filter(i=>i.location_id===current),below=list.filter(i=>qty(i)s+qty(i)*Number(i.unit_cost||0),0);content.innerHTML='
Your role: '+esc(role())+'
Products'+list.length+'
Below par'+below.length+'
Stock value'+fmt(valuation)+'
Draft orders'+orders.filter(o=>o.location_id===current&&o.status==='Draft').length+'
'+(canChange()?'':'')+'
'+(screen==='counts'?countTable():screen==='orders'?orderTable():screen==='roles'?roleTable():table())+'

Inventory data is shared in Supabase. Purchase orders remain drafts until handled with your supplier; SkyTab sync is not connected.

';content.querySelector('#inv-location').onchange=e=>{current=e.target.value;draw()};content.querySelectorAll('[data-screen]').forEach(b=>b.onclick=()=>{screen=b.dataset.screen;draw()});content.querySelector('#inv-add')?.addEventListener('click',()=>productForm());content.querySelectorAll('[data-count]').forEach(b=>b.onclick=()=>countForm(b.dataset.count));content.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>productForm(b.dataset.edit));content.querySelector('#inv-draft')?.addEventListener('click',draftOrder);content.querySelector('#inv-grant')?.addEventListener('click',grantRole)} function countTable(){return '

Physical count history

Count full bottles and partial bottles as decimals (for example, 0.5 for half a bottle). Latest count is used for on-hand totals.

'+counts.filter(x=>x.location_id===current).slice(0,80).map(x=>'').join('')+'
WhenProductBottlesCounted by
'+esc(new Date(x.counted_at).toLocaleString())+''+esc(items.find(i=>i.id===x.item_id)?.name||'Item')+''+esc(x.counted_units)+''+esc(x.counted_by?.slice(0,8))+'
'} function orderTable(){const eligible=items.filter(i=>i.location_id===current&&qty(i)

Purchase ordering

Suggested quantities are par minus latest physical count, rounded up to whole bottles. Always verify with your supplier before submitting.

'+(canChange()?'':'

Viewing only: manager or owner approval required to create orders.

')+''+orders.filter(o=>o.location_id===current).map(o=>'').join('')+'
DateVendorStatusLines
'+esc(new Date(o.created_at).toLocaleDateString())+''+esc(o.vendor)+''+esc(o.status)+''+esc(o.lines?.length||0)+'
'} function roleTable(){return '

Employee inventory permissions

Viewer: read-only · Counter: record counts · Manager: manage products and draft orders · Owner: full access. To authorize a new employee, ask them for their account ID shown on their Inventory page.

'+(role()==='owner'?'
':'

Only an owner can grant inventory permissions.

')+''+access.filter(a=>a.location_id===current).map(a=>'').join('')+'
AccountRole
'+esc(a.user_id?.slice(0,13))+'…'+esc(a.role)+'
'} function productForm(id){const p=items.find(i=>i.id===id)||{};const work=content.querySelector('#inv-work');work.innerHTML='

'+(id?'Edit bottle':'Add bottle')+'

';work.querySelector('#inv-cancel').onclick=draw;work.querySelector('form').onsubmit=async e=>{e.preventDefault();const f=e.target;const obj={location_id:current,name:f.elements.namedItem('name').value.trim(),category:f.elements.namedItem('category').value,size_ml:Number(f.elements.namedItem('size_ml').value),unit_cost:Number(f.elements.namedItem('unit_cost').value),par_units:Number(f.elements.namedItem('par_units').value),on_hand_units:Number(f.elements.namedItem('on_hand_units').value),vendor:f.elements.namedItem('vendor').value.trim(),updated_at:new Date().toISOString()};try{await request('/inventory_items'+(id?'?id=eq.'+id:''),{method:id?'PATCH':'POST',body:id?obj:obj});await load();messageText('Product saved')}catch(err){messageText(err.message)}}} function countForm(id){const p=items.find(i=>i.id===id);const work=content.querySelector('#inv-work');work.innerHTML='

Count: '+esc(p.name)+'

Enter full bottles plus fractional bottles: 2.5 means two full bottles and one half bottle.

';work.querySelector('#inv-cancel').onclick=draw;work.querySelector('form').onsubmit=async e=>{e.preventDefault();try{await request('/inventory_counts',{method:'POST',body:{item_id:id,location_id:current,counted_units:Number(e.target.elements.count.value),counted_by:user.id}});await load();messageText('Physical count recorded')}catch(err){messageText(err.message)}}} async function draftOrder(){const low=items.filter(i=>i.location_id===current&&qty(i)x.vendor||'Unassigned vendor'))];if(!confirm('Create '+vendors.length+' draft purchase order(s), grouped by vendor? No orders will be sent.'))return;try{for(const vendor of vendors){let group=low.filter(i=>(i.vendor||'Unassigned vendor')===vendor);await request('/inventory_orders',{method:'POST',body:{location_id:current,vendor,status:'Draft',created_by:user.id,lines:group.map(i=>({item_id:i.id,name:i.name,bottles:Math.ceil(Number(i.par_units)-qty(i)),unit_cost:Number(i.unit_cost)}))}})}await load();messageText('Draft purchase orders created')}catch(e){messageText(e.message)}} async function grantRole(){const id=content.querySelector('#inv-user')?.value?.trim(),r=content.querySelector('#inv-role')?.value;if(!/^[0-9a-f-]{36}$/i.test(id||'')){messageText('Enter a valid employee UUID');return}try{const prev=await request('/inventory_access?user_id=eq.'+encodeURIComponent(id)+'&location_id=eq.'+encodeURIComponent(current));await request('/inventory_access'+(prev.length?'?user_id=eq.'+encodeURIComponent(id)+'&location_id=eq.'+encodeURIComponent(current):''),{method:prev.length?'PATCH':'POST',body:{user_id:id,location_id:current,role:r}});await load();messageText('Access updated')}catch(e){messageText(e.message)}} bC.onclick=()=>{document.documentElement.classList.remove('hh-inventory-active');bC.classList.add('active');bI.classList.remove('active')}; bI.onclick=()=>{document.documentElement.classList.add('hh-inventory-active');bI.classList.add('active');bC.classList.remove('active');load()}; })();