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 ?
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:
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).
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.
Building the schedule is mostly clicking and dragging.
Click any existing shift pill on the calendar — same modal opens, but with a Delete button alongside Save.
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.
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.
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.
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.
The proposal shows you four key things at the top:
Below that, you'll see:
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.
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.
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.
The People tab is where you add, edit, and manage your team.
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.
The Admin tab has six sub-tabs. Each controls a different aspect of how scheduling works.
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.
Configurable scheduling constraints used by Auto-Schedule. Five types:
Each rule can be scoped to all locations (default) or one specific location, and to all employees or one specific user.
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.
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.
Messages that appear on the Overview tab for everyone. Good for "Holiday party Saturday Dec 14!" or "Reminder: dress code starts Monday."
Per-location, per-day-of-week open and close times. Auto-Schedule uses these to set default shift bounds.
Quick reference for what each role can do.
| Action | Staff | Manager | Owner |
|---|---|---|---|
| {row[0]} | {row[1]||"—"} | {row[2]||"—"} | {row[3]||"—"} |
Reach out and we'll help you sort it out.
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.
| 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 | ; })}
|---|
Just a moment.
If {recoveryEmail} matches an account, we've sent a link to reset your password.
Check spam if it doesn't arrive in a couple minutes. The link expires after one hour.