// 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