const {useState,useEffect,useCallback} = React; // ═══ CONFIG ═══ const API = window.location.origin; const DEV_USER = new URLSearchParams(window.location.search).get("user") || null; const SUPABASE_URL = "https://cekiibugdwrmqcshbicu.supabase.co"; const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNla2lpYnVnZHdybXFjc2hiaWN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzc0NzgwNDAsImV4cCI6MjA5MzA1NDA0MH0.3wvtLWV7aJgTwa7iJ-J6ljCNCThZal4p78ISu0y1CRo"; // Token storage const getToken = () => { try { return JSON.parse(localStorage.getItem("cc_session"))?.access_token || null; } catch { return null; } }; const setSession = s => localStorage.setItem("cc_session", JSON.stringify(s)); const clearSession = () => localStorage.removeItem("cc_session"); // Supabase auth calls const supabaseLogin = async (email, password) => { const r = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, { method: "POST", headers: {"Content-Type":"application/json","apikey":SUPABASE_ANON_KEY}, body: JSON.stringify({email, password}) }); const data = await r.json(); if (!r.ok) throw new Error(data.error_description || data.msg || "Login failed"); return data; }; const supabaseRefresh = async (refresh_token) => { const r = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, { method: "POST", headers: {"Content-Type":"application/json","apikey":SUPABASE_ANON_KEY}, body: JSON.stringify({refresh_token}) }); const data = await r.json(); if (!r.ok) return null; return data; }; // ═══ API ═══ // Platform-admin "acting as" — when a Bruton Intelligence admin scopes // themselves into a different franchise, the chosen franchise_id is // stored here and sent on every API call via X-Active-Franchise-Id. const getActiveFranchiseId = () => { try { return localStorage.getItem("cc_active_franchise_id") || null; } catch { return null; } }; const setActiveFranchiseId = (id) => { try { if (id) localStorage.setItem("cc_active_franchise_id", id); else localStorage.removeItem("cc_active_franchise_id"); } catch {} }; const apiFetch = async (path, opts={}, _retried=false) => { const headers = {"Content-Type":"application/json", ...opts.headers}; // Forward the active-franchise override on every request (no-op if not set // or the caller isn't a platform admin — backend silently ignores) const af = getActiveFranchiseId(); if (af) headers["X-Active-Franchise-Id"] = af; // Dev bypass via ?user= URL param if (DEV_USER) { headers["x-dev-user-id"] = DEV_USER; const sep = path.includes("?") ? "&" : "?"; const r = await fetch(`${API}${path}${sep}dev_user_id=${DEV_USER}`, {...opts, headers}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail||r.statusText); } return r.json(); } // Production: use JWT token const token = getToken(); if (token) headers["Authorization"] = `Bearer ${token}`; const r = await fetch(`${API}${path}`, {...opts, headers}); if (r.status === 401 && !_retried) { // Try refreshing the token once before giving up const sess = JSON.parse(localStorage.getItem("cc_session")||"{}"); if (sess.refresh_token) { const newSess = await supabaseRefresh(sess.refresh_token); if (newSess?.access_token) { setSession(newSess); return apiFetch(path, opts, true); } } clearSession(); window.location.reload(); return null; } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail||r.statusText); } return r.json(); }; // ═══ HELPERS ═══ const dk = d => d.toISOString().slice(0,10); const mondayOf = d => { const m=new Date(d); m.setDate(m.getDate()-((m.getDay()+6)%7)); return m; }; const weekDays = mon => Array.from({length:7},(_,i)=>{ const d=new Date(mon); d.setDate(d.getDate()+i); return d; }); const isSame = (a,b) => dk(a)===dk(b); const apiTime = t => { if(!t)return""; const m=t.match(/(\d+):(\d+)\s*(AM|PM)/i); if(!m)return t; return `${+m[1]}:${m[2]}${m[3][0].toLowerCase()}`; }; const to24 = t => { const[tm,p]=t.split(/(?=[ap])/); let[h,m]=tm.split(":").map(Number); if(p==="p"&&h!==12)h+=12; if(p==="a"&&h===12)h=0; return `${String(h).padStart(2,"0")}:${String(m||0).padStart(2,"0")}:00`; }; const pT = t => { const[tm,p]=t.split(/(?=[ap])/); let[h,m]=tm.split(":").map(Number); if(p==="p"&&h!==12)h+=12; if(p==="a"&&h===12)h=0; return h+(m||0)/60; }; const fmtH = h => { const hr=Math.floor(h),mn=Math.round((h-hr)*60); return mn?`${hr}h ${mn}m`:`${hr}h`; }; const DS = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]; const DF = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]; const MS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const RC = { Stylist:{bg:"#dbeafe",tx:"#1e40af",bd:"#93c5fd"}, Receptionist:{bg:"#f3e8ff",tx:"#7c3aed",bd:"#c4b5fd"} }; const LC = [ {bg:"#dbeafe",tx:"#1e40af",bd:"#93c5fd",pill:"#3b82f6"}, // blue (e.g. Fort Worth) {bg:"#fce7f3",tx:"#9d174d",bd:"#f9a8d4",pill:"#ec4899"}, // pink (e.g. Arlington) {bg:"#d1fae5",tx:"#065f46",bd:"#6ee7b7",pill:"#10b981"}, // green {bg:"#fef3c7",tx:"#92400e",bd:"#fcd34d",pill:"#f59e0b"}, // amber {bg:"#e0e7ff",tx:"#3730a3",bd:"#a5b4fc",pill:"#6366f1"}, // indigo {bg:"#ffe4e6",tx:"#9f1239",bd:"#fda4af",pill:"#f43f5e"}, // rose ]; const getLC = (locId, locs) => { const idx = (locs||[]).findIndex(l=>l.id===locId); return LC[idx>=0?idx%LC.length:0]; }; const TOPTS = (()=>{ const a=[]; for(let h=6;h<=21;h++) for(let m=0;m<60;m+=10){ const hr=h>12?h-12:h===0?12:h; a.push(`${hr}:${String(m).padStart(2,"0")}${h>=12?"p":"a"}`); } return a; })(); // ═══ STYLES ═══ const SI = {width:"100%",padding:"8px 10px",borderRadius:6,border:"1px solid #d1d5db",fontSize:14,color:"#111",background:"#fff",outline:"none"}; const CD = {background:"#fff",borderRadius:10,border:"1px solid #e5e7eb",padding:16,marginBottom:12}; const GH = {padding:"8px 4px",textAlign:"center",borderBottom:"1px solid #e5e7eb",borderRight:"1px solid #f3f4f6",background:"#f9fafb",fontSize:11,fontWeight:600,color:"#6b7280"}; const NB = {width:36,height:36,borderRadius:8,border:"1px solid #e5e7eb",background:"#fff",fontSize:18,color:"#374151",cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center"}; const AV = {width:36,height:36,borderRadius:8,background:"#f3f4f6",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,fontWeight:700,color:"#6b7280",flexShrink:0}; const IB = {width:30,height:30,borderRadius:6,border:"1px solid #e5e7eb",background:"#fff",display:"flex",alignItems:"center",justifyContent:"center",cursor:"pointer",fontSize:14,color:"#6b7280"}; const SH = {fontSize:13,fontWeight:700,color:"#374151",marginBottom:10,textTransform:"uppercase",letterSpacing:.5}; const BTN = {padding:"10px 16px",borderRadius:8,border:"none",fontSize:14,fontWeight:600,cursor:"pointer",background:"#3b82f6",color:"#fff"}; // ═══ SHARED COMPONENTS ═══ const Badge = ({s}) => { const m = {DRAFT:["#fef3c7","#92400e"],PUBLISHED:["#d1fae5","#065f46"],PENDING:["#fef3c7","#92400e"],APPROVED:["#d1fae5","#065f46"],DENIED:["#fee2e2","#991b1b"]}; const[bg,c] = m[s]||m.DRAFT; return {s}; }; const Toast = ({msg}) => msg ?
{msg}
: null; // ═══ SHIFT MODAL ═══ function ShiftModal({mode,shift,name,day,positions,templates,locId,userLocs,locs,onSave,onDelete,onClose}) { const[start,setStart]=useState(shift?.start||"9:50a"); const[end,setEnd]=useState(shift?.end||"6:00p"); const[role,setRole]=useState(shift?.role||positions?.[0]||"Stylist"); const[selTpl,setSelTpl]=useState(null); const[selLoc,setSelLoc]=useState(locId||userLocs?.[0]||""); const ok = pT(end)>pT(start); // Filter locs to only the user's assigned locations const userLocList = (locs||[]).filter(l=>userLocs?.includes(l.id)); return
e.stopPropagation()} style={{background:"#fff",borderRadius:12,padding:24,width:"100%",maxWidth:380,boxShadow:"0 20px 60px rgba(0,0,0,.2)"}}>

{mode==="add"?"Add Shift":"Edit Shift"}

{name}
{day}
Role
{(positions||["Stylist"]).map(p => )}
{templates?.length>0 && mode==="add" &&
{templates.map(t => { const active = selTpl===t.id; return ; })}
} {userLocList.length>1 && mode==="add" && <>
Location
{userLocList.map(l => { const lc = getLC(l.id, locs); const sel = selLoc===l.id; return ; })}
}
Start
End
{ok &&
{start} – {end} · {fmtH(pT(end)-pT(start))}
}
{mode==="edit" && }
; } // ═══ HELP / DOCUMENTATION VIEW ═══ // Comprehensive how-to guide for owners/managers/staff. // Image placeholders use the component below — drop real // screenshots into /static/help/ and replace the placeholders later. function HelpImg({alt,caption}){ return
📷 {alt}
{caption &&
{caption}
}
; } const HelpRole = ({role}) => { const colors = { owner: {bg:"#dbeafe",tx:"#1e40af",label:"Owner only"}, manager: {bg:"#f3e8ff",tx:"#7c3aed",label:"Manager+"}, staff: {bg:"#f3f4f6",tx:"#6b7280",label:"All roles"}, }[role] || {bg:"#f3f4f6",tx:"#6b7280",label:role}; return {colors.label}; }; const HelpTip = ({children}) =>
💡 {children}
; const HelpNote = ({children}) =>
ℹ️ {children}
; const HelpSec = ({id,title,role,children}) =>

{title}{role && }

{children}
; function HelpView({me,isOwner,isMgr}){ const TOC = [ {id:"start",label:"Getting Started"}, {id:"schedule",label:"The Schedule View"}, {id:"shifts",label:"Adding & Editing Shifts"}, {id:"publish",label:"Publishing the Schedule"}, {id:"auto",label:"✨ Auto-Schedule (AI)"}, {id:"timeoff",label:"Time-Off Requests"}, {id:"people",label:"Managing People"}, {id:"admin",label:"Admin Settings"}, {id:"perms",label:"Permissions Reference"}, {id:"best",label:"Best Practices"}, {id:"contact",label:"Need More Help?"}, ]; return
{/* Header */}
Bruton Intelligence
How-to Guide
Everything you need to run your salon's scheduling — from the daily basics to AI-generated monthly schedules.
{/* Table of contents */}
Jump to
{TOC.map(t=>{t.label})}
{/* ── Getting Started ── */}

When your manager creates your account, you'll receive an email from Bruton Intelligence with a link to set your password. Click the link, choose a password (at least 8 characters), and you're in.

Forgot your password? Click "Forgot password?" on the sign-in screen and enter your email. We'll send you a reset link. The link expires after one hour.

Your role determines what you can do. You'll see one of three badges next to your name in the header:

  • Owner — you set up the franchise, can manage everything across all locations
  • Manager — you can manage schedules, users, and time-off at your assigned locations
  • Staff — you can view your schedule and request time off
Show or hide your password by clicking the 👁 eye icon in the password field on the sign-in screen.
{/* ── Schedule view ── */}

The Schedule tab is the heart of the app. You can view it as a calendar (week-at-a-glance grid) or a list (compact daily breakdown).

Navigating weeks

  • Use the ‹ › arrows above the calendar to step backwards/forwards one week at a time
  • Click Today to jump back to the current week
  • Use the date picker to jump to a specific date

Reading the colors

  • Solid colored pills — published shifts visible to staff
  • Hatched / dashed pills — draft shifts (managers only see these until published)
  • Red CLOSED days — the salon is closed (no shifts can be scheduled)
  • Gray "OFF" or "REQ" badges — approved or pending time-off
  • Different colors per location — Fort Worth might be blue, Arlington pink, etc. — helps multi-location stylists see at a glance which store they're assigned to

Filtering by location

The "All Locations" dropdown in the top-right header lets you switch between viewing all locations or just one. Owners see this; managers see only their assigned locations.

{/* ── Adding/editing shifts ── */}

Building the schedule is mostly clicking and dragging.

Add a new shift

  1. Click an empty cell on the calendar (where you see a + placeholder)
  2. The Add Shift modal opens, pre-filled with the stylist and date
  3. Pick a Role (Stylist or Receptionist if applicable)
  4. Optional: click a Template chip (Full Day, Half Day, etc.) to auto-fill times
  5. If the stylist works at multiple locations, pick which Location the shift is at
  6. Adjust Start and End times if needed
  7. Click Add Shift

Edit an existing shift

Click any existing shift pill on the calendar — same modal opens, but with a Delete button alongside Save.

Drag to move

You can drag any shift to a different day in the same week. Hold and drop on the target day. If the stylist already has a shift on that target day, the two shifts swap.

Editing a published shift automatically sets it back to DRAFT. You'll need to re-publish to push the change to staff.
{/* ── Publishing ── */}

Shifts have two states: DRAFT (only managers see them) and PUBLISHED (staff sees them too). This lets you build out a full schedule without staff seeing it half-finished.

To publish: scroll below the calendar grid. You'll see a yellow banner saying "Unpublished changes" with a Publish button. Click it. All draft shifts in the current week are pushed to PUBLISHED status.

To unpublish (rare — useful if you need to make widespread changes): click the green "Published" banner's Unpublish button to revert all shifts in the week back to DRAFT.

Get into the habit of publishing weekly, on the same day. Staff will learn when to expect their schedule to drop.
{/* ── Auto-Schedule ── */}

The Auto-Schedule feature is what makes Bruton Intelligence different from every other scheduling tool. It generates a complete monthly schedule from your last 13 months of POS data — services delivered, busy days, slow days — and respects your stylists' hour caps, time-off requests, and custom rules.

When to use it

The 15th–20th of each month is the sweet spot — you generate next month's schedule, review it, tweak a few things, and have it published a week before the month starts. You can also re-run mid-month if circumstances change drastically.

Generating a schedule

  1. Click ✨ Auto-Schedule on the Schedule tab
  2. Pick the Month (defaults to next month) and Location (defaults to whatever location you have selected)
  3. Click ✨ Generate [Month] Schedule
  4. The AI analyzes your POS history and proposes a schedule in ~3 seconds

Reviewing the proposal

The proposal shows you four key things at the top:

  • Shifts proposed — total number of shifts the AI built
  • Stylists scheduled — how many of your active stylists got shifts
  • Total scheduled — sum of all shift hours across the month
  • Demand filled % — what fraction of forecast demand the proposal covers (95%+ green, 85-94% amber, <85% red)

Below that, you'll see:

  • A per-stylist hours table — each stylist's monthly total vs their target. Green = on target, amber = a bit low, red = over the cap.
  • A daily coverage heatmap — color-coded grid showing forecast demand vs assigned hours per day.
  • A warnings panel — flagged days where demand exceeds available stylist capacity.

Save or discard

If the proposal looks good, click Save as Drafts →. All shifts get inserted into the calendar as DRAFT — you can edit individual shifts before publishing.

If something's off, click ← Back to tweak the inputs and re-generate, or Discard to throw it away.

Demo mode: The expandable "Demo mode (advanced)" section lets you preview the schedule for any other store in the chain by entering its POS site ID. Useful for showing the system to other franchise owners. Save is disabled in demo mode (you can't write data to a store you don't own). For Auto-Schedule to work well, your stylists need to be linked to their POS records. Open the People tab, edit each stylist, and pick their match from the Link to POS Employee dropdown. Without this link, that stylist's historical service patterns can't be used.
{/* ── Time off ── */}

Requesting time off (staff)

  1. Open the Time Off tab
  2. Click + Request
  3. Pick a Start and (optional) End date
  4. Choose a Reason (Vacation, Personal, Medical, Family)
  5. Click Submit

You'll see your request appear with a yellow PENDING badge. Once your manager approves or denies it, the badge changes color and a notification appears on the schedule.

Approving requests

Managers see all pending requests at the top of the Time Off tab. Click Approve to grant the request (the schedule grid will mark those days "OFF" for that stylist), or Deny to reject. Denying prompts you for a reason that's emailed to the staff member.

Time-off requests that overlap a blackout period are rejected automatically. Set blackouts in the Admin tab (peak season, holidays, etc.).
{/* ── People ── */}

The People tab is where you add, edit, and manage your team.

Adding a team member

  1. Click + Add in the People tab
  2. Enter First name, Last name, Email (required for login)
  3. Pick Employment Type (Full-Time or Part-Time)
  4. Pick their Schedule Position(s): Stylist, Receptionist, or both. Leave blank if they don't get scheduled (e.g., your accountant).
  5. Pick their Location(s) — at least one required. Multi-location staff can be assigned to several.
  6. Link to POS Employee — pick their match from the dropdown so Auto-Schedule can use their history
  7. Owners only: pick Role (Staff or Manager)
  8. Click Add Team Member — they'll receive an invite email immediately
The POS Employee dropdown auto-suggests matches by name. If you see someone you don't recognize, hover over the entry — it shows last-seen date and total days worked at the location.

POS link indicator

Each team member's row shows either a green "POS ✓" badge (linked) or an amber "POS ⚠" badge (not linked). Unlinked stylists get excluded from Auto-Schedule's history-based assignments.

Action buttons (right side of each row)

  • Edit — change name, locations, positions, role, or POS link
  • Resend invite — sends a fresh password reset email (useful if they lost the original)
  • Deactivate — removes from active scheduling but keeps history. Their login is also temporarily banned.
  • Reactivate — restores a deactivated user
  • Delete — permanently removes the user and their account. Cannot be undone.
{/* ── Admin ── */}

The Admin tab has six sub-tabs. Each controls a different aspect of how scheduling works.

Schedule Templates

Reusable shift presets that show as quick-pick chips when adding a shift. Common examples: "Full Day" (9:50a–6:00p), "Half Day" (12:50p–6:00p). Add a template once, use it forever.

Rules

Configurable scheduling constraints used by Auto-Schedule. Five types:

  • WEEKLY_HOURS_RANGE — e.g., "Part-time should be 15–25 hours/week" (param_1=min, param_2=max)
  • MAX_CONSECUTIVE_DAYS — e.g., "Audrey max 3 consecutive days" (param_1=days)
  • WEEKEND_OFF_PER_MONTH — e.g., "Try for 1 Saturday and 1 Sunday off each month" (param_1=Sat, param_2=Sun)
  • SHIFT_START_OFFSET — e.g., "Shifts start 10 minutes before opening" (param_1=offset minutes)
  • CUSTOM — free-form rules; description is read by the AI when polishing schedules. e.g., "Lanore works full shifts but only Tue/Wed/Sat"

Each rule can be scoped to all locations (default) or one specific location, and to all employees or one specific user.

Closed Dates

Single days the salon is shut (holidays, deep cleaning, training). Schedule cells turn red and shifts can't be created on these dates. Optional location scope.

Blackouts

Date ranges where time-off requests are blocked. Doesn't close the salon — just rejects new time-off submissions during that range. Use for peak season (back-to-school, holiday rush) or critical events.

Announcements

Messages that appear on the Overview tab for everyone. Good for "Holiday party Saturday Dec 14!" or "Reminder: dress code starts Monday."

Business Hours

Per-location, per-day-of-week open and close times. Auto-Schedule uses these to set default shift bounds.

{/* ── Permissions ── */}

Quick reference for what each role can do.

{[ ["View their own published shifts","✓","✓","✓"], ["Request time off","✓","✓","✓"], ["View entire weekly schedule (own location)","Published only","✓","✓"], ["Add/edit/delete shifts","","✓","✓"], ["Publish/unpublish weekly schedule","","✓","✓"], ["Drag shifts to reorder","","✓","✓"], ["Approve/deny time-off requests","","✓","✓"], ["Add/edit team members","","✓","✓"], ["Promote staff to manager / change roles","","","✓"], ["Run Auto-Schedule (AI)","","✓","✓"], ["Edit Admin settings (rules, templates, etc.)","","✓","✓"], ["See all locations","","Their location(s)","✓"], ["Resend invite emails","","✓","✓"], ["Permanently delete users","","✓","✓"], ].map((row,i)=>)}
Action Staff Manager Owner
{row[0]} {row[1]||"—"} {row[2]||"—"} {row[3]||"—"}
{/* ── Best practices ── */}

Onboarding new staff

  1. Create their user in the People tab — they'll get an invite email
  2. Have them set their password from the email
  3. Once they sign in, they should bookmark the live URL and add it to their phone home screen
  4. Walk them through the Schedule view and Time Off tab — most staff only ever use those two

Monthly scheduling cadence

  1. 15th–20th of the month: open Auto-Schedule, generate the next month's draft
  2. Review the proposal — check that hour totals look right per stylist, no warnings on critical days
  3. Save as Drafts
  4. Review individual shifts in the calendar — drag/edit as needed for known requests
  5. ~7 days before month-end: publish the new month's schedule
  6. Communicate publication to staff (text, post in break room, etc.)

Common pitfalls

  • Forgetting to link new hires to POS means Auto-Schedule won't include them in history-based logic
  • Setting blackouts too aggressively can frustrate staff — use them only for genuinely critical periods
  • Publishing too late in the month leaves staff scrambling to plan their lives — publish early
  • Never permanently delete a user with historical shifts unless you're sure — deactivate instead, which preserves history
{/* ── Contact ── */}

Reach out and we'll help you sort it out.

  • Email: cdbruton@gmail.com
  • For questions about your franchise's specific setup, contact your franchise owner first
Bruton Intelligence · v3.0
; } // ═══ PLATFORM ADMIN VIEW ═══ // Cross-franchise dashboard for Bruton Intelligence platform admins. // Shows every franchise with summary stats, lets you switch active scope, // and onboards new franchises. function PlatformView({me, activeFranchiseId, setActiveFranchiseId, tt, fetchAll}) { const[franchises,setFranchises]=useState([]); const[loading,setLoading]=useState(true); const[showCreate,setShowCreate]=useState(false); const[creating,setCreating]=useState(false); const[editingFranchise,setEditingFranchise]=useState(null); // franchise object being edited const[newF,setNewF]=useState({ name:"", slug:"", plan_tier:"starter", owner_email:"", owner_first_name:"", owner_last_name:"", location_name:"", location_city:"", location_state:"", location_pos_site_id:"", }); const loadFranchises = async () => { setLoading(true); try { const r = await apiFetch("/api/scheduling/v3/platform/franchises"); setFranchises(r); } catch(e) { tt(e.message); } finally { setLoading(false); } }; useEffect(() => { loadFranchises(); }, []); const switchTo = (fid) => { if(fid === me?.franchise_id) { setActiveFranchiseId(null); // back to home — clear override } else { setActiveFranchiseId(fid); } tt("Switched franchise — reloading..."); // Reload all data so views reflect the new franchise scope setTimeout(() => { fetchAll(); }, 100); }; const createFranchise = async () => { if(!newF.name.trim() || !newF.owner_email.trim() || !newF.owner_first_name.trim() || !newF.owner_last_name.trim()) { tt("Fill in franchise name and owner details"); return; } setCreating(true); try { const body = { name: newF.name.trim(), slug: newF.slug.trim() || undefined, plan_tier: newF.plan_tier, owner_email: newF.owner_email.trim(), owner_first_name: newF.owner_first_name.trim(), owner_last_name: newF.owner_last_name.trim(), locations: newF.location_name.trim() ? [{ name: newF.location_name.trim(), city: newF.location_city.trim() || null, state: newF.location_state.trim() || null, pos_site_id: newF.location_pos_site_id.trim() || null, }] : [], }; const r = await apiFetch("/api/scheduling/v3/platform/franchises", { method: "POST", body: JSON.stringify(body), }); tt(r.message || "Franchise created"); setShowCreate(false); setNewF({name:"",slug:"",plan_tier:"starter",owner_email:"",owner_first_name:"",owner_last_name:"",location_name:"",location_city:"",location_state:"",location_pos_site_id:""}); await loadFranchises(); } catch(e) { tt(e.message); } finally { setCreating(false); } }; return
{/* Header */}
Bruton Intelligence
Platform Admin
Cross-franchise operations — onboarding, support, troubleshooting.
{/* Acting-as banner */} {activeFranchiseId && activeFranchiseId !== me?.franchise_id && (()=>{ const af = franchises.find(f=>f.id===activeFranchiseId); return
Acting as: {af?.name || activeFranchiseId}
You're viewing and editing this franchise's data. Switch back when you're done.
; })()} {/* Action bar */}

Franchises ({franchises.length})

{/* Create-franchise form */} {showCreate &&
Franchise Name *
setNewF({...newF,name:e.target.value})}/>
Slug (optional)
setNewF({...newF,slug:e.target.value})}/>
Plan Tier
First Owner
First Name *
setNewF({...newF,owner_first_name:e.target.value})}/>
Last Name *
setNewF({...newF,owner_last_name:e.target.value})}/>
Owner Email *
setNewF({...newF,owner_email:e.target.value})}/>
First Location (optional — owner can add later)
setNewF({...newF,location_name:e.target.value})}/>
setNewF({...newF,location_city:e.target.value})}/> setNewF({...newF,location_state:e.target.value.toUpperCase()})}/> setNewF({...newF,location_pos_site_id:e.target.value})}/>
The owner will receive an invite email immediately and can sign in once they set their password.
} {/* Franchises list */} {loading ?
Loading franchises...
: franchises.length === 0 ?
No franchises yet. Click "+ New Franchise" to add the first one.
: <>{editingFranchise && setEditingFranchise(null)} onSaved={async()=>{ await loadFranchises(); /* keep modal open so they can keep editing */ }} />}{franchises.map(f => { const isHome = f.id === me?.franchise_id; const isActive = activeFranchiseId === f.id || (!activeFranchiseId && isHome); return
{f.name} {f.status} {f.plan_tier} {isHome && YOUR HOME} {isActive && !isHome && ACTING AS}
{f.slug}
👥 {f.user_count} {f.user_count===1?"user":"users"} 📍 {f.location_count} {f.location_count===1?"location":"locations"} 👑 {f.active_owner_count} owner{f.active_owner_count===1?"":"s"} {f.last_shift_date && 📅 last shift {f.last_shift_date}}
{!isActive && } {isActive && !isHome && You're here}
; })}}
; } // ═══ FRANCHISE EDIT MODAL ═══ // Platform-admin tool for editing franchise details and managing locations. // Locations are the billing unit, so only platform admins can add/remove them. function FranchiseEditModal({franchise, tt, onClose, onSaved}) { const[f,setF]=useState({ name: franchise.name || "", slug: franchise.slug || "", plan_tier: franchise.plan_tier || "starter", status: franchise.status || "active", }); const[locations,setLocations]=useState([]); const[loading,setLoading]=useState(true); const[saving,setSaving]=useState(false); const[showAddLoc,setShowAddLoc]=useState(false); const[newLoc,setNewLoc]=useState({name:"",city:"",state:"",pos_site_id:"",address:"",zip:""}); const[editingLocId,setEditingLocId]=useState(null); const[editLoc,setEditLoc]=useState({}); const loadLocations = async () => { setLoading(true); try { const r = await apiFetch(`/api/scheduling/v3/platform/franchises/${franchise.id}/locations?include_inactive=true`); setLocations(r); } catch(e) { tt(e.message); } finally { setLoading(false); } }; useEffect(() => { loadLocations(); }, [franchise.id]); const saveFranchise = async () => { setSaving(true); try { await apiFetch(`/api/scheduling/v3/platform/franchises/${franchise.id}`, { method: "PUT", body: JSON.stringify({ name: f.name.trim() || null, slug: f.slug.trim() || null, plan_tier: f.plan_tier, status: f.status, }), }); tt("Franchise saved"); onSaved && onSaved(); } catch(e) { tt(e.message); } finally { setSaving(false); } }; const addLocation = async () => { if(!newLoc.name.trim()) { tt("Enter a location name"); return; } try { await apiFetch(`/api/scheduling/v3/platform/franchises/${franchise.id}/locations`, { method: "POST", body: JSON.stringify({ name: newLoc.name.trim(), pos_site_id: newLoc.pos_site_id.trim() || null, address: newLoc.address.trim() || null, city: newLoc.city.trim() || null, state: newLoc.state.trim() || null, zip: newLoc.zip.trim() || null, }), }); tt("Location added"); setShowAddLoc(false); setNewLoc({name:"",city:"",state:"",pos_site_id:"",address:"",zip:""}); await loadLocations(); onSaved && onSaved(); } catch(e) { tt(e.message); } }; const startEditLoc = (loc) => { setEditingLocId(loc.id); setEditLoc({ name: loc.name || "", pos_site_id: loc.pos_site_id || "", address: loc.address || "", city: loc.city || "", state: loc.state || "", zip: loc.zip || "", is_active: loc.is_active, }); }; const saveEditLoc = async () => { try { await apiFetch(`/api/scheduling/v3/platform/franchises/${franchise.id}/locations/${editingLocId}`, { method: "PUT", body: JSON.stringify({ name: editLoc.name.trim() || null, pos_site_id: editLoc.pos_site_id.trim() || null, address: editLoc.address.trim() || null, city: editLoc.city.trim() || null, state: editLoc.state.trim() || null, zip: editLoc.zip.trim() || null, is_active: editLoc.is_active, }), }); tt("Location saved"); setEditingLocId(null); await loadLocations(); onSaved && onSaved(); } catch(e) { tt(e.message); } }; const deactivateLoc = async (locId) => { if(!confirm("Deactivate this location? Historical shifts stay intact but no new shifts can be created.")) return; try { await apiFetch(`/api/scheduling/v3/platform/franchises/${franchise.id}/locations/${locId}`, { method: "DELETE", }); tt("Location deactivated"); await loadLocations(); onSaved && onSaved(); } catch(e) { tt(e.message); } }; const reactivateLoc = async (locId) => { try { await apiFetch(`/api/scheduling/v3/platform/franchises/${franchise.id}/locations/${locId}`, { method: "PUT", body: JSON.stringify({is_active: true}), }); tt("Location reactivated"); await loadLocations(); onSaved && onSaved(); } catch(e) { tt(e.message); } }; return
e.stopPropagation()} style={{background:"#fff",borderRadius:14,width:"100%",maxWidth:720,maxHeight:"92vh",boxShadow:"0 30px 80px rgba(0,0,0,.3)",overflow:"hidden",display:"flex",flexDirection:"column"}}>
Edit Franchise
{franchise.name}
{/* Franchise details */}

Details

Name
setF({...f,name:e.target.value})}/>
Slug
setF({...f,slug:e.target.value})}/>
Plan Tier
Status
{/* Locations */}

Locations ({locations.filter(l=>l.is_active).length} active)

Each active location is a billing line item. Owners cannot add locations themselves — only platform admins.
{showAddLoc &&
setNewLoc({...newLoc,name:e.target.value})}/>
setNewLoc({...newLoc,city:e.target.value})}/> setNewLoc({...newLoc,state:e.target.value.toUpperCase()})}/> setNewLoc({...newLoc,pos_site_id:e.target.value})}/>
setNewLoc({...newLoc,address:e.target.value})}/>
} {loading ?
Loading locations...
: locations.length === 0 ?
No locations yet — add the first one above.
: locations.map(loc => editingLocId === loc.id ? (
setEditLoc({...editLoc,name:e.target.value})}/>
setEditLoc({...editLoc,city:e.target.value})}/> setEditLoc({...editLoc,state:e.target.value.toUpperCase()})}/> setEditLoc({...editLoc,pos_site_id:e.target.value})}/>
setEditLoc({...editLoc,address:e.target.value})}/>
) : (
{loc.name} {!loc.is_active && INACTIVE} {loc.pos_site_id && POS {loc.pos_site_id}}
{[loc.city,loc.state].filter(Boolean).join(", ") || "no location set"}
{loc.is_active ? : }
))}
; } // ═══ AUTO-SCHEDULE MODAL ═══ // Shows a proposed schedule generated by the v3 forecast/generate endpoint // alongside per-stylist hour totals, day-by-day coverage, and any warnings. // Click "Generate" to fetch the proposal (no save). Click "Save as Drafts" // to commit matched stylists' shifts to the calendar. function AutoScheduleModal({asMonth,setAsMonth,asLocId,setAsLocId,asPosSite,setAsPosSite,asProposal,setAsProposal,asLoading,runAutoSchedule,applyProposal,locs,onClose}){ const fmtMonthLabel = m => { if(!m||m.length<7) return m; const [y,mo] = m.split("-"); const d = new Date(parseInt(y), parseInt(mo)-1, 1); return d.toLocaleDateString(undefined,{month:"long",year:"numeric"}); }; // Build month options: this month + next 12 const monthOpts = (()=>{const a=[];const now=new Date();for(let i=0;i<13;i++){const d=new Date(now.getFullYear(),now.getMonth()+i,1);a.push(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}`);}return a;})(); return
e.stopPropagation()} style={{background:"#fff",borderRadius:14,padding:0,width:"100%",maxWidth:780,maxHeight:"92vh",boxShadow:"0 30px 80px rgba(0,0,0,.3)",overflow:"hidden",display:"flex",flexDirection:"column"}}> {/* Header w/ AI gradient */}
Bruton Intelligence
✨ Auto-Schedule
{/* Step 1: configuration */} {!asProposal && <>

Generates a proposed monthly schedule using your last 13 months of POS data. Honors stylist hour caps (35hr target, 37hr cap), time-off requests, closed dates, and your custom rules. Returns a draft you can review before publishing.

Month
Location
Demo mode (advanced)
POS site_id override
setAsPosSite(e.target.value)}/>
Use this to forecast against any Cookie Cutters store in the chain. Save is disabled in this mode (preview only).
} {/* Step 2: proposal results */} {asProposal && setAsProposal(null)}/>}
{/* Footer w/ actions when proposal is shown */} {asProposal &&
}
; } // ═══ PROPOSAL VIEW ═══ // Renders the GenerateResponseV3 — stats, per-stylist weekly hours, daily coverage, warnings. function ProposalView({proposal,onBack}){ const summary = proposal.coverage.reduce((acc,c)=>{ acc.demand += c.demand_hours; acc.assigned += c.assigned_hours; return acc; },{demand:0,assigned:0}); const fillPct = summary.demand>0 ? Math.round(100*summary.assigned/summary.demand) : 0; return
{/* Top stats */}
{proposal.shifts.length}
shifts proposed
{proposal.stylists.length}
stylists scheduled
{Math.round(summary.assigned)}h
total scheduled
=95?"#10b981":fillPct>=85?"#f59e0b":"#dc2626"}`}}>
{fillPct}%
demand filled
{/* Warnings */} {proposal.warnings.length>0 &&
⚠ {proposal.warnings.length} day{proposal.warnings.length===1?"":"s"} understaffed vs forecast
{proposal.warnings.slice(0,5).map((w,i)=>
{w}
)} {proposal.warnings.length>5 &&
...and {proposal.warnings.length-5} more
}
} {/* Stylists weekly hours */}

Per-Stylist Hours

{proposal.weekly_hours.map(s=>{ const stylist=proposal.stylists.find(x=>x.pos_employee_id===s.pos_employee_id); const overTarget=stylist&&s.total_hours>stylist.weekly_cap; const underTarget=stylist&&s.total_hours ; })}
Stylist Type Total Hrs Target Cap
{s.stylist_name} {stylist?.employment_type||"UNKNOWN"} {s.total_hours}h {stylist?.weekly_target||"—"}h/wk {stylist?.weekly_cap||"—"}h/wk
{/* Daily coverage */}

Daily Coverage

{proposal.coverage.map(c=>{ const ratio = c.demand_hours>0 ? c.assigned_hours/c.demand_hours : 1; const bg = ratio>=0.95 ? "#d1fae5" : ratio>=0.8 ? "#fef3c7" : "#fee2e2"; const border = ratio>=0.95 ? "#10b981" : ratio>=0.8 ? "#f59e0b" : "#dc2626"; return
{c.day_of_week}
{new Date(c.date).getDate()}
{c.assigned_hours}/{c.demand_hours}h
{c.stylists_assigned} ppl
; })}
; } // ═══ CALENDAR GRID ═══ function CalendarGrid({days,canE,vU,gs,isCl,getTOR,wh,setModal,drag,setDrag,moveShift,locs,vL}) { // Smart location default: use the currently viewed location if the user belongs to it, otherwise first const defLoc = (u) => { if(vL && vL!=="All") { const match = locs.find(l=>l.name===vL); if(match && u.location_ids?.includes(match.id)) return match.id; } return u.location_ids?.[0]; }; return
Staff
{days.map((d,i)=>{ const td=isSame(d,new Date()), cl=isCl(d); return
{DS[d.getDay()]}
{d.getDate()}
{cl &&
CLOSED
}
; })} {vU.map(u =>
{u.display_name}
{fmtH(wh[u.id]||0)}
{days.map((d,di)=>{ const s=gs(u.id,d), cl=isCl(d), tr=getTOR(u.id,d); const rc=s?RC[s.role]||RC.Stylist:null, dr=s?.status==="DRAFT"; return
{e.preventDefault();e.dataTransfer.dropEffect="move"}:undefined} onDrop={canE?e=>{e.preventDefault();if(drag?.uid===u.id&&drag.s?.id)moveShift(drag.s.id,dk(d));setDrag(null)}:undefined} onClick={()=>{ if(cl)return; if(canE&&!s&&!tr) setModal({mode:"add",uid:u.id,dk:dk(d),day:`${DF[d.getDay()]}, ${MS[d.getMonth()]} ${d.getDate()}`,name:u.display_name,positions:u.positions,locId:defLoc(u),userLocs:u.location_ids}); else if(canE&&s) setModal({mode:"edit",uid:u.id,dk:dk(d),day:`${DF[d.getDay()]}, ${MS[d.getMonth()]} ${d.getDate()}`,name:u.display_name,shift:s,positions:u.positions,locId:s.locId||u.location_ids?.[0],userLocs:u.location_ids}); }}> {cl ? : tr&&!s ?
{tr.status==="APPROVED"?"OFF":"REQ"}
: s ?
setDrag({uid:u.id,s})} onDragEnd={()=>setDrag(null)} style={{width:"100%",padding:"3px 2px",borderRadius:4,fontSize:9,fontWeight:600,textAlign:"center",lineHeight:1.3,background:rc.bg,color:rc.tx,border:`1px ${dr?"dashed":"solid"} ${rc.bd}`,opacity:dr?.7:1,position:"relative"}}> {dr &&
}
{s.locName&&locs.length>1?(()=>{const lc=getLC(s.locId,locs);return
{s.locName}
;})():null}{s.start}
{s.end}
: canE ?
+
: }
; })}
)}
Hours
{days.map((d,i)=>{ let h=0; vU.forEach(u=>{const s=gs(u.id,d);if(s)h+=pT(s.end)-pT(s.start)}); return
{h>0?fmtH(h):""}
; })}
; } // ═══ LIST VIEW ═══ function ListView({days,canE,vU,gs,isCl,getTOR,setModal,locs}) { return
{days.map(d=>{ const cl=isCl(d), td=isSame(d,new Date()); const ds=vU.map(u=>({u,s:gs(u.id,d),t:getTOR(u.id,d)})).filter(x=>x.s||x.t); return
{DF[d.getDay()]}, {MS[d.getMonth()]} {d.getDate()} {cl?CLOSED:{ds.filter(x=>x.s).length} shifts}
{cl ?
Closed
: ds.length===0 ?
No shifts
:
{ds.map(({u,s,t})=>{ if(t&&!s) return
{u.avatar_initials}
{u.first_name} {u.last_name}
Time Off
; const rc=RC[s?.role]||RC.Stylist, dr=s?.status==="DRAFT"; return
canE&&s&&setModal({mode:"edit",uid:u.id,dk:dk(d),day:`${DF[d.getDay()]}, ${MS[d.getMonth()]} ${d.getDate()}`,name:u.display_name,shift:s,positions:u.positions,locId:s.locId||u.location_ids?.[0],userLocs:u.location_ids})}>
{u.avatar_initials}
{u.first_name} {u.last_name}
{s.start} – {s.end}
{s.role} {s.locName&&locs.length>1&&(()=>{const lc=getLC(s.locId,locs);return {s.locName}})()}
{dr && }
; })}
}
; })}
; } // ═══ TIME OFF VIEW ═══ function TimeOffView({me,isM,tor,vL,fetchAll,tt}) { const[show,setShow]=useState(false); const[f,setF]=useState({start_date:"",end_date:"",reason:"",notes:""}); const[deny,setDeny]=useState(null); const[dc,setDc]=useState(""); const fl=isM?tor.filter(r=>vL==="All"||r.location_name===vL):tor.filter(r=>r.user_id===me.id); const submit=async()=>{ if(!f.start_date){tt("Pick a start date");return} if(!f.reason){tt("Pick a reason");return} try{ await apiFetch("/api/scheduling/v3/time-off",{method:"POST",body:JSON.stringify({ start_date:f.start_date, end_date:f.end_date||f.start_date, reason:f.reason, notes:f.notes||null, })}); await fetchAll(); setF({start_date:"",end_date:"",reason:"",notes:""}); setShow(false); tt("Submitted"); }catch(e){tt(e.message)} }; const review=async(id,st,cm)=>{ try{await apiFetch(`/api/scheduling/v3/time-off/${id}`,{method:"PUT",body:JSON.stringify({status:st,comment:cm})});await fetchAll();tt(st)}catch(e){tt(e.message)} }; const del=async id=>{ try{await apiFetch(`/api/scheduling/v3/time-off/${id}`,{method:"DELETE"});await fetchAll();tt("Removed")}catch(e){tt(e.message)} }; return

{isM?"Requests":"My Time Off"}

{!isM&&}
{show &&
Start *
setF({...f,start_date:e.target.value})}/>
End
setF({...f,end_date:e.target.value})}/>
Reason *
Additional details (optional)