// JOXE Admin Portal — Panel de gestión del barbero // ==================== STORES (Turso via API + localStorage cache) ==================== const ADMIN_KEY = "joxe_admin_v1"; const APPT_KEY = "joxe_turnos_v1"; const SES_KEY = "joxe_admin_session"; // stores the password as session token const EMP_SES_KEY = "joxe_emp_session"; // { id, name, role } for employee sessions const EMP_TOKEN_KEY = "joxe_emp_token"; // signed JWT for authenticated API reads // ---- Auth helpers — admin ---- const getToken = () => sessionStorage.getItem(SES_KEY) ?? ""; const isAuthed = () => !!sessionStorage.getItem(SES_KEY); const doLogin = (pw) => sessionStorage.setItem(SES_KEY, pw); const doLogout = () => { sessionStorage.removeItem(SES_KEY); sessionStorage.removeItem(EMP_SES_KEY); sessionStorage.removeItem(EMP_TOKEN_KEY); }; // ---- Auth helpers — employee ---- const getEmpSession = () => { try { return JSON.parse(sessionStorage.getItem(EMP_SES_KEY)); } catch { return null; } }; const isEmpAuthed = () => !!sessionStorage.getItem(EMP_SES_KEY); const doEmpLogin = (emp) => sessionStorage.setItem(EMP_SES_KEY, JSON.stringify(emp)); const doEmpLogout = () => sessionStorage.removeItem(EMP_SES_KEY); // Picks admin token or employee JWT, whichever is available const storeToken = () => sessionStorage.getItem(SES_KEY) || sessionStorage.getItem(EMP_TOKEN_KEY) || ""; const adminHeaders = () => ({ "Content-Type": "application/json", "Authorization": `Bearer ${getToken()}`, }); // Como adminHeaders pero también acepta el JWT de empleado — para endpoints abiertos a cualquier staff const staffHeaders = () => ({ "Content-Type": "application/json", "Authorization": `Bearer ${storeToken()}`, }); // ---- Admin store (services, revenue, settings) ---- const DEFAULT_ADMIN = () => ({ salonName: "JOXE", stylists: ["Joxe", "Laura M.", "Camila R."], cancelledIds: [], services: [ { id:"s1", name:"Corte mujer", price:85000, dur:60, active:true }, { id:"s2", name:"Corte hombre", price:45000, dur:40, active:true }, { id:"s3", name:"Balayage", price:280000, dur:180, active:true, note:"desde" }, { id:"s4", name:"Color correction", price:320000, dur:240, active:true, note:"desde" }, { id:"s5", name:"Color raíz", price:120000, dur:90, active:true }, { id:"s6", name:"Keratina", price:260000, dur:180, active:true, note:"desde" }, { id:"s7", name:"Asesoría de imagen", price:180000, dur:90, active:true }, { id:"s8", name:"Peinado novia", price:220000, dur:120, active:true, note:"desde" }, ], employees: [ { id:"e1", name:"Joxe", role:"Estilista", services:["s1","s2","s3","s4","s5","s6","s7","s8"], active:true }, { id:"e2", name:"Laura M.", role:"Estilista", services:["s1","s2","s3","s5","s6","s8"], active:true }, { id:"e3", name:"Camila R.",role:"Colorista", services:["s3","s4","s5","s6"], active:true }, ], revenue: [], noShowIds: [], noShowFine: { enabled: false, defaultAmount: 0, byDay: {} }, archivedEmployees: [], chairsCount: 3, chairAssignments: {}, }); const loadAdminCache = () => { try { const s = JSON.parse(localStorage.getItem(ADMIN_KEY)); const d = DEFAULT_ADMIN(); return s ? { ...d, ...s, services: s.services || d.services, employees: s.employees || d.employees } : d; } catch { return DEFAULT_ADMIN(); } }; const useAdmin = () => { const [a, setA] = React.useState(loadAdminCache); const stateRef = React.useRef(null); const setAWithRef = React.useCallback((next) => { stateRef.current = next; setA(next); }, []); const pull = React.useCallback(async () => { try { const res = await fetch("/api/admin", { headers: adminHeaders() }); if (res.status === 401) { doLogout(); return; } if (!res.ok) return; const data = await res.json(); localStorage.setItem(ADMIN_KEY, JSON.stringify(data)); const ref = stateRef.current; const base = (ref && typeof ref !== "function") ? ref : loadAdminCache(); setAWithRef({ ...base, ...data }); } catch {} }, [setAWithRef]); React.useEffect(() => { if (!isAuthed()) return; pull(); const t = setInterval(pull, 8000); return () => clearInterval(t); }, [pull]); const setAdmin = React.useCallback(async (fn) => { // Use in-memory ref (fresh state) instead of stale localStorage const current = stateRef.current ?? loadAdminCache(); const next = typeof fn === "function" ? fn(current) : fn; setAWithRef(next); localStorage.setItem(ADMIN_KEY, JSON.stringify(next)); try { await fetch("/api/admin", { method: "POST", headers: adminHeaders(), body: JSON.stringify(next), }); } catch (err) { console.warn("[admin] save failed", err.message); } }, [setAWithRef]); return [a, setAdmin]; }; // ---- CRM store (client profiles + loyalty) ---- const CRM_KEY = "joxe_crm_v1"; const loadCrmCache = () => { try { const s = JSON.parse(localStorage.getItem(CRM_KEY)); return s || {}; } catch { return {}; } }; const useCrm = () => { const [crm, setCrm] = React.useState(loadCrmCache); const pull = React.useCallback(async () => { if (!isAuthed()) return; try { const res = await fetch("/api/crm", { headers: adminHeaders() }); if (res.status === 401) { doLogout(); return; } if (!res.ok) return; const data = await res.json(); localStorage.setItem(CRM_KEY, JSON.stringify(data)); setCrm(data); } catch {} }, []); React.useEffect(() => { if (!isAuthed()) return; pull(); const t = setInterval(pull, 10000); return () => clearInterval(t); }, [pull]); const setCrmData = React.useCallback(async (fn) => { const current = loadCrmCache(); const next = typeof fn === "function" ? fn(current) : fn; setCrm(next); localStorage.setItem(CRM_KEY, JSON.stringify(next)); try { await fetch("/api/crm", { method: "POST", headers: adminHeaders(), body: JSON.stringify(next), }); } catch (err) { console.warn("[crm] save failed", err.message); } }, []); return [crm, setCrmData]; }; // ---- Appointment store (shared with portal) ---- const DEFAULT_APPTS = () => ({ appointments:[], active:[], completed:[], blockedSlots:[], timeWarnings:[] }); const loadApptCache = () => { try { const s = JSON.parse(localStorage.getItem(APPT_KEY)); return s ? { ...DEFAULT_APPTS(), ...s } : DEFAULT_APPTS(); } catch { return DEFAULT_APPTS(); } }; const useAppts = () => { const [s, setS] = React.useState(loadApptCache); const pull = React.useCallback(async () => { try { const t = storeToken(); const headers = t ? { "Authorization": `Bearer ${t}` } : {}; const res = await fetch("/api/store", { headers }); if (!res.ok) return; const data = await res.json(); localStorage.setItem(APPT_KEY, JSON.stringify(data)); setS(data); } catch {} }, []); React.useEffect(() => { pull(); const t = setInterval(pull, 5000); let bc; try { bc = new BroadcastChannel("joxe_turnos"); bc.addEventListener("message", pull); } catch {} return () => { clearInterval(t); try { bc?.close(); } catch {} }; }, [pull]); const setAppts = React.useCallback(async (fn) => { const current = loadApptCache(); const next = typeof fn === "function" ? fn(current) : fn; setS(next); localStorage.setItem(APPT_KEY, JSON.stringify(next)); try { await fetch("/api/store", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${storeToken()}` }, body: JSON.stringify(next), }); try { new BroadcastChannel("joxe_turnos").postMessage({ type:"update" }); } catch {} } catch (err) { console.warn("[appts] save failed", err.message); } }, []); return [s, setAppts]; }; // ==================== HELPERS ==================== const todayStr = () => new Date().toISOString().split("T")[0]; const genId = () => Math.random().toString(36).slice(2, 10); const TIMES = ["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"]; // Rango horario que muestra la vista diaria vertical de la agenda (10am a 10pm) const AGENDA_HOURS = ["10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00","21:00","22:00"]; // ---- Booking availability helpers (mirror booking portal) ---- const timeToMin = (t) => { const [h,m]=String(t).split(":").map(Number); return h*60+(m||0); }; const minToTime = (mins) => `${Math.floor(mins/60)}:${String(mins%60).padStart(2,"0")}`; // Salon business hours by JS getDay(): 0=dom … 6=sab (mar–vie 9-20, sáb 8-18, dom/lun cerrado) const BUSINESS_HOURS = { 0:null, 1:null, 2:["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], 3:["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], 4:["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], 5:["9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00","19:00","20:00"], 6:["8:00","9:00","10:00","11:00","12:00","13:00","14:00","15:00","16:00","17:00","18:00"], }; const CLOSE_TIME_MIN = { 2:21*60, 3:21*60, 4:21*60, 5:21*60, 6:19*60 }; const WORK_DAY_KEYS = ["dom","lun","mar","mie","jue","vie","sab"]; const dayOfWeekIdx = (dateStr) => new Date(dateStr+"T12:00").getDay(); const isClosedDay = (dateStr) => !BUSINESS_HOURS[dayOfWeekIdx(dateStr)]; const slotsForDate = (dateStr) => BUSINESS_HOURS[dayOfWeekIdx(dateStr)] || []; const closesAtMin = (dateStr) => CLOSE_TIME_MIN[dayOfWeekIdx(dateStr)] ?? 0; // false if the slot+duration falls outside the employee's configured work hours const empWorksOnSlot = (emp, date, timeStr, dur) => { if (!emp?.workHours) return true; // no schedule configured — no restriction const day = emp.workHours[WORK_DAY_KEYS[dayOfWeekIdx(date)]]; if (!day?.active) return false; const s = timeToMin(timeStr), e = s + dur; return s >= timeToMin(day.start) && e <= timeToMin(day.end); }; const METHODS = ["Efectivo","Transferencia","Datáfono","Nequi"]; const ROLES = ["Estilista","Colorista","Manicurista","Pedicurista","Barbero","Maquillador/a","Masajista","Recepcionista","Otro"]; const DAYS_WORK = [ { key:"lun", label:"Lun" }, { key:"mar", label:"Mar" }, { key:"mie", label:"Mié" }, { key:"jue", label:"Jue" }, { key:"vie", label:"Vie" }, { key:"sab", label:"Sáb" }, { key:"dom", label:"Dom" }, ]; const DEFAULT_WORK_HOURS = () => ({ lun:{ active:true, start:"09:00", end:"18:00" }, mar:{ active:true, start:"09:00", end:"18:00" }, mie:{ active:true, start:"09:00", end:"18:00" }, jue:{ active:true, start:"09:00", end:"18:00" }, vie:{ active:true, start:"09:00", end:"18:00" }, sab:{ active:false, start:"09:00", end:"14:00" }, dom:{ active:false, start:"09:00", end:"14:00" }, }); const PAY_COLORS = { Efectivo:"#C29E66", Transferencia:"#8ab0ff", Datáfono:"#C46666", Nequi:"#66C499", Multa:"#e07070" }; const fmtCOP = (n) => n == null ? "—" : "$" + Number(n).toLocaleString("es-CO"); const fmtDateShort = (d) => !d ? "—" : new Date(d+"T12:00").toLocaleDateString("es-CO",{day:"numeric",month:"short"}); const fmtDateMed = (d) => !d ? "—" : new Date(d+"T12:00").toLocaleDateString("es-CO",{weekday:"short",day:"numeric",month:"short"}); const fmtDateTime = (ts) => !ts ? "—" : new Date(ts).toLocaleTimeString("es-CO",{hour:"2-digit",minute:"2-digit"}); const PENDING_EXPIRE_MS = 60 * 60 * 1000; // 1 hora const getAllAppts = (store, cancelledIds=[], noShowIds=[]) => { const activeIds = new Set(store.active.map(a=>a.id)); const completedIds = new Set(store.completed.map(a=>a.id)); const resolveStatus = (a, fallback) => { if (noShowIds.includes(a.id)) return "no-show"; if (cancelledIds.includes(a.id)) return "cancelled"; if (fallback === "pending" && (Date.now() - (a.createdAt||0)) > PENDING_EXPIRE_MS) return "expired"; return fallback; }; const result = []; store.appointments.forEach(a => { if (activeIds.has(a.id) || completedIds.has(a.id)) return; result.push({...a, computedStatus: resolveStatus(a, a.status || "scheduled")}); }); store.active.forEach(a => { result.push({...a, computedStatus: resolveStatus(a, a.status)}); }); store.completed.forEach(a => result.push({...a, computedStatus: resolveStatus(a, "completed")})); return result.sort((a,b)=>{ if ((b.date||"") !== (a.date||"")) return (b.date||"").localeCompare(a.date||""); return (a.time||"").localeCompare(b.time||""); }); }; const getWeekDates = (offset=0) => { const now = new Date(); const day = now.getDay(); const monday = new Date(now); monday.setDate(now.getDate() - (day===0?6:day-1) + offset*7); return Array.from({length:6},(_,i)=>{ const d = new Date(monday); d.setDate(monday.getDate()+i); return d.toISOString().split("T")[0]; }); }; // ==================== UI TOKENS ==================== const C = { bg:"#0C0C0C", s1:"#111", s2:"#181818", s3:"#222", bdr:"rgba(245,241,234,0.1)", bdr2:"rgba(245,241,234,0.2)", gold:"#C29E66", text:"#F5F1EA", muted:"rgba(245,241,234,0.5)", muted2:"rgba(245,241,234,0.25)", red:"#C46666", green:"#66C499", blue:"#8ab0ff", }; const Mono = ({children,style,as:Tag="span"}) => ( {children} ); const pseudoQR = (text) => { const size = 25; const grid = Array.from({length:size},()=>Array(size).fill(false)); let h = 0; for (let i=0;i>>0; for (let y=0;y>>0; grid[y][x]=(h&0xff)<128; } const finder=(cx,cy)=>{ for (let y=0;y<7;y++) for (let x=0;x<7;x++) { const on=(x===0||x===6||y===0||y===6)||(x>=2&&x<=4&&y>=2&&y<=4); if(cx+x=0&&cx+x=0&&cy+y { const url = chairNum ? `${window.location.origin}/CheckIn.html#puesto-${chairNum}` : `${window.location.origin}/CheckIn.html#chair-${empId}`; const [dataUrl, setDataUrl] = React.useState(null); const [showUrl, setShowUrl] = React.useState(false); const [copied, setCopied] = React.useState(false); const copyUrl = () => { navigator.clipboard.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; React.useEffect(() => { let cancelled = false; const generate = () => { const lib = window.QRCode; if (!lib) return false; lib.toDataURL(url, { width: size, margin: 2, color: { dark: "#0C0C0C", light: "#F5F1EA" } }) .then(d => { if (!cancelled) setDataUrl(d); }) .catch(() => {}); return true; }; if (!generate()) { const i = setInterval(() => { if (generate()) clearInterval(i); }, 200); return () => { cancelled = true; clearInterval(i); }; } return () => { cancelled = true; }; }, [url, size]); const printQR = () => { if (!dataUrl) return; const w = window.open("", "_blank", "width=480,height=600"); w.document.write(`QR · ${empName}
${empName}
JOXE · Check-In