= loyalty.target
? "rgba(102,196,153,0.3)" : "rgba(245,241,234,0.1)"}`,
padding: "28px 32px",
}}>
Programa de lealtad · {loyalty.reward}
= loyalty.target ? "#66C499" : "#C29E66",
lineHeight: 1,
}}>
{loyalty.visits}
/{loyalty.target}
{loyalty.redeemed > 0 && (
{loyalty.redeemed} canje{loyalty.redeemed !== 1 ? "s" : ""} previos
)}
{loyalty.visits >= loyalty.target ? (
¡Felicidades! Tienes un {loyalty.reward}.
Avisa en recepción cuando llegues.
) : (
<>
Te faltan{" "}
{loyalty.target - loyalty.visits} visita{loyalty.target - loyalty.visits !== 1 ? "s" : ""}
{" "}
para tu {loyalty.reward}.
>
)}
{/* Progress bar */}
= loyalty.target ? "#66C499" : "#C29E66",
width: `${Math.min(loyalty.visits / loyalty.target * 100, 100)}%`,
transition: "width 0.5s ease",
}} />
{/* Dots */}
{Array.from({ length: Math.min(loyalty.target, 20) }).map((_, i) => (
= loyalty.target ? "#66C499" : "#C29E66")
: "rgba(245,241,234,0.08)",
border: `1px solid ${i < loyalty.visits
? (loyalty.visits >= loyalty.target ? "#66C499" : "#C29E66")
: "rgba(245,241,234,0.12)"}`,
transition: "background 0.3s",
}} />
))}
{loyalty.visits > 20 && (
+{loyalty.visits - 20}
)}
)}
{/* Upcoming appointments */}
Próximas citas
{upcoming.length > 0 && ` · ${upcoming.length}`}
+ Agendar →
{upcoming.length === 0 ? (
) : (
{upcoming.map(a => {
const s = STATUS[a.computedStatus] || STATUS.scheduled;
const isQROpen = showQR === a.id;
const isLive = a.computedStatus === "waiting" || a.computedStatus === "in-service";
return (
{a.service}
{a.stylist && <>{a.stylist} · >}
{fmtDate(a.date)}
{a.time && <> · {a.time}>}
{/* Actions for upcoming appointments */}
{a.computedStatus === "scheduled" && (
)}
{a.date === todayStr && (a.computedStatus === "scheduled" || a.computedStatus === "waiting") && (
Check-In →
)}
{isQROpen && (
Muestra este código en recepción para activar tu turno
{a.code}
)}
);
})}
)}
{/* History */}
{history.length > 0 && (
Historial · {history.length} visita{history.length !== 1 ? "s" : ""}
{history.map(a => {
const s = STATUS[a.computedStatus] || STATUS.completed;
return (
{fmtShort(a.date)}
{a.service}
{a.stylist && (
{a.stylist}
)}
{a.time}
{s.label}
);
})}
)}
{/* Empty state */}
{appts.length === 0 && (
)}
);
};
// ============================================================
// PAGE 0 — HOME
// ============================================================
// ── Portal auth gate ─────────────────────────────────────────
const PORTAL_SES_ADMIN = "joxe_admin_session";
const PORTAL_SES_EMP = "joxe_agenda_session";
const isPortalAuthed = () =>
!!sessionStorage.getItem(PORTAL_SES_ADMIN) ||
!!sessionStorage.getItem(PORTAL_SES_EMP);
const PortalLoginGate = ({ onAuth }) => {
const [tab, setTab] = React.useState("admin"); // "admin" | "empleado"
const [pw, setPw] = React.useState("");
const [empList, setEmpList] = React.useState([]);
const [selId, setSelId] = React.useState("");
const [pin, setPin] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
React.useEffect(() => {
fetch("/api/catalog")
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.employees) setEmpList(d.employees.filter(e => e.active !== false)); })
.catch(() => {});
}, []);
const loginAdmin = async () => {
if (!pw) return;
setLoading(true); setErr("");
try {
const res = await fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw }),
});
const data = await res.json();
if (!res.ok || !data.ok) { setErr("Contraseña incorrecta."); setPw(""); setLoading(false); return; }
sessionStorage.setItem(PORTAL_SES_ADMIN, pw);
onAuth();
} catch { setErr("Error de conexión."); }
setLoading(false);
};
const loginEmp = async () => {
if (!selId || !pin) return;
setLoading(true); setErr("");
try {
const res = await fetch("/api/agenda", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "login", empId: selId, pin }),
});
const data = await res.json();
if (!res.ok) { setErr(data.error || "PIN incorrecto."); setPin(""); setLoading(false); return; }
sessionStorage.setItem(PORTAL_SES_EMP, JSON.stringify(data.employee));
if (data.token) sessionStorage.setItem("joxe_emp_token", data.token);
onAuth();
} catch { setErr("Error de conexión."); }
setLoading(false);
};
const tabStyle = (active) => ({
flex: 1, padding: "10px 0", background: "none", border: "none",
borderBottom: active ? "2px solid #C29E66" : "2px solid transparent",
color: active ? "#C29E66" : "rgba(245,241,234,0.45)",
fontFamily: "'JetBrains Mono', monospace", fontSize: 10,
letterSpacing: "0.2em", textTransform: "uppercase", cursor: "pointer",
transition: "color 0.2s",
});
return (
Sitio web ↗
}
/>
}>
Acceso restringido
Portal de equipo.
Ingresa con tu cuenta de administrador o empleado para continuar.
{/* Tabs */}
{tab === "admin" && (
{ setPw(e.target.value); setErr(""); }}
onKeyDown={e => e.key === "Enter" && loginAdmin()}
placeholder="••••••••"
style={{
width: "100%", padding: "14px 16px", marginBottom: 20,
background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)",
color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace", fontSize: 15,
}}
/>
)}
{tab === "empleado" && (
{ setPin(e.target.value.replace(/\D/g, "")); setErr(""); }}
onKeyDown={e => e.key === "Enter" && selId && pin && loginEmp()}
placeholder="••••"
inputMode="numeric" maxLength={8}
style={{
width: "100%", padding: "14px 16px", marginBottom: 20,
background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)",
color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace",
fontSize: 20, letterSpacing: "0.3em",
}}
/>
)}
{err && (
{err}
)}
);
};
const HomePortal = () => {
const [store] = useStore();
const [authed, setAuthed] = React.useState(isPortalAuthed);
const doLogout = () => {
sessionStorage.removeItem(PORTAL_SES_ADMIN);
sessionStorage.removeItem(PORTAL_SES_EMP);
setAuthed(false);
};
if (!authed) return
setAuthed(true)} />;
const empSession = (() => { try { return JSON.parse(sessionStorage.getItem(PORTAL_SES_EMP)); } catch { return null; } })();
const isAdmin = !!sessionStorage.getItem(PORTAL_SES_ADMIN);
const label = isAdmin ? "Admin" : (empSession?.name ?? "Empleado");
return (
Sitio web ↗
Check-In
{empSession && !isAdmin && (
Mi Agenda ◈
)}
{isAdmin && (
Admin ⊛
)}
}
/>
}>
Bienvenido
Reserva. Escanea. Entra.
Un sistema simple en tres tiempos. Agenda tu cita desde casa,
escanea el QR al llegar, y espera cómodo mientras te llamamos
por nombre.
{[
{
n: "01", title: "Agendar cita", subtitle: "Cliente",
desc: "Elige servicio, fecha y estilista. Recibe tu QR personal.",
href: "Booking.html", cta: "Reservar ahora",
primary: true,
},
{
n: "02", title: "Mi Cuenta", subtitle: "Cliente",
desc: "Consulta tus citas, el estado en sala y tus puntos de lealtad.",
href: "Cuenta.html", cta: "Ver mi cuenta",
},
...(empSession ? [{
n: "03", title: "Mi Agenda", subtitle: "Empleado",
desc: "Revisa las solicitudes de citas asignadas a ti y confírmalas al recibir el comprobante.",
href: "Agenda.html", cta: "Ver solicitudes →",
accent: true,
}] : []),
{
n: empSession ? "04" : "03", title: "Escanear QR", subtitle: "Recepción",
desc: "Valida el ticket y activa el turno al llegar al salón.",
href: "Scan.html", cta: "Abrir escáner",
},
{
n: empSession ? "05" : "04", title: "Pantalla de sala", subtitle: "Lobby",
desc: "Muestra la cola en vivo — proyecta en pantalla grande.",
href: "Lobby.html", cta: "Ver sala",
},
{
n: empSession ? "06" : "05", title: "Check-In", subtitle: "Cliente · Silla",
desc: "Escanea el QR de tu silla para confirmar asistencia o cerrar el servicio.",
href: "CheckIn.html", cta: "Ir a Check-In",
},
].map(c => (
e.currentTarget.style.transform = "translateY(-4px)"}
onMouseLeave={e => e.currentTarget.style.transform = "translateY(0)"}
>
{c.n} · {c.subtitle}
{c.title}
{c.desc}
{c.cta} →
))}
{/* Stats */}
{[
["En espera", store.active.filter(a => a.status === "waiting").length],
["En silla", store.active.filter(a => a.status === "in-service").length],
["Agendados", store.appointments.length],
["Atendidos hoy", store.completed.length],
].map(([l, n]) => (
{String(n).padStart(2, "0")}
{l}
))}
);
};
// ============================================================
// PAGE 5 — CHECK-IN (silla de estilista)
// Admin: ve la agenda del día con acciones.
// Todos los demás: confirman asistencia con cédula.
// ============================================================
// Helpers locales para leer/escribir el estado de admin sin importar admin.jsx
const ADMIN_KEY_CI = "joxe_admin_v1";
const loadAdminCI = () => {
try { return JSON.parse(localStorage.getItem(ADMIN_KEY_CI)) || {}; } catch { return {}; }
};
const saveAdminCI = (next) => localStorage.setItem(ADMIN_KEY_CI, JSON.stringify(next));
// Calcula el status visible de una cita igual que getAllAppts en admin.jsx
const resolveApptStatus = (appt, activeIds, completedIds, noShowIds, cancelledIds) => {
if (noShowIds.includes(appt.id)) return "no-show";
if (cancelledIds.includes(appt.id)) return "cancelled";
if (completedIds.has(appt.id)) return "completed";
if (activeIds.has(appt.id)) return "waiting";
return appt.status || "scheduled";
};
const CheckInPortal = () => {
const [store, setStore] = useStore();
const catalog = useCatalog();
const hash = window.location.hash.replace('#', '');
const chairNum = hash.startsWith('puesto-') ? Number(hash.replace('puesto-', '')) : null;
const legacyId = !chairNum && hash.startsWith('chair-') ? hash.replace('chair-', '') : null;
const resolvedEmpId = chairNum
? (catalog.chairAssignments?.[chairNum] || null)
: legacyId;
const employee = resolvedEmpId
? (catalog.employees.find(e => e.id === resolvedEmpId) || null)
: null;
const isAdmin = !!sessionStorage.getItem("joxe_admin_session");
const headerRight = (
← Inicio
);
// ── MODO ADMIN: agenda del estilista ───────────────────────
if (isAdmin) {
return
;
}
// ── MODO CLIENTE / ESTILISTA / ANÓNIMO: check-in por cédula ─
return
;
};
// ── Vista admin ─────────────────────────────────────────────
const CheckInAdminView = ({ store, setStore, employee, headerRight }) => {
const [adminState, setAdminState] = React.useState(loadAdminCI);
const [checkoutId, setCheckoutId] = React.useState(null);
const [price, setPrice] = React.useState('');
const [note, setNote] = React.useState('');
const [done, setDone] = React.useState(false);
const [confirm, setConfirm] = React.useState(null); // { appt, action: 'noshow'|'checkin' }
const noShowIds = adminState.noShowIds || [];
const cancelledIds = adminState.cancelledIds || [];
const activeIds = new Set(store.active.map(a => a.id));
const completedIds = new Set(store.completed.map(a => a.id));
const today = todayStr();
// Todas las citas de hoy para este estilista (o todas si no hay silla)
const todayAppts = React.useMemo(() => {
const all = [
...store.appointments.map(a => ({ ...a, _src: 'scheduled' })),
...store.active.map(a => ({ ...a, _src: 'active' })),
...store.completed.map(a => ({ ...a, _src: 'completed' })),
];
// Dedup por id: active/completed override scheduled (last wins)
const seen = new Map();
all.forEach(a => seen.set(a.id, a));
return [...seen.values()]
.filter(a => a.date === today && (!employee || a.stylist === employee.name))
.map(a => ({
...a,
computedStatus: resolveApptStatus(a, activeIds, completedIds, noShowIds, cancelledIds),
}))
.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
}, [store, employee, today, noShowIds, cancelledIds]);
// Cliente activo en silla (para checkout)
const inService = checkoutId
? store.active.find(a => a.id === checkoutId) || null
: null;
const manualCheckIn = (appt) => {
setStore(s => {
const alreadyActive = s.active.some(a => a.id === appt.id);
const rawAppt = s.appointments.find(a => a.id === appt.id) || appt;
return {
...s,
appointments: s.appointments.map(a =>
a.id === appt.id ? { ...a, checkedIn: true, checkedInAt: Date.now() } : a
),
active: alreadyActive ? s.active : [
...s.active,
{ ...rawAppt, checkedIn: true, checkedInAt: Date.now(), activatedAt: Date.now(), status: "waiting", position: s.active.length + 1 },
],
};
});
setConfirm(null);
};
const CI_DAY_KEYS = ["dom","lun","mar","mie","jue","vie","sab"];
const markNoShow = async (appt) => {
const admin = loadAdminCI();
const ids = [...(admin.noShowIds || []), appt.id];
const fine = admin.noShowFine;
let next = { ...admin, noShowIds: ids };
if (fine?.enabled) {
// Use same day-key format as admin.jsx ("lun","mar"...) to match byDay config
const day = CI_DAY_KEYS[new Date(appt.date + "T12:00").getDay()];
const amount = (fine.byDay?.[day] > 0 ? fine.byDay[day] : fine.defaultAmount) || 0;
if (amount > 0) {
const fineId = `ns-${appt.id}`;
// Guard: prevent double-registering the same fine
const alreadyExists = (admin.revenue || []).some(r => r.id === fineId);
if (!alreadyExists) {
next = {
...next,
revenue: [...(admin.revenue || []), {
id: fineId, date: appt.date, type: 'no-show-fine',
client: appt.name, service: appt.service,
phone: appt.phone || "",
stylist: appt.stylist || "",
method: "Multa",
note: `Incumplimiento · ${appt.code || appt.id}`,
amount,
createdAt: Date.now(),
}],
};
}
}
}
saveAdminCI(next);
setAdminState(next);
setConfirm(null);
// Persist to server so admin panel syncs correctly
const token = sessionStorage.getItem("joxe_admin_session") || "";
if (token) {
try {
await fetch("/api/admin", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(next),
});
} catch (err) {
console.warn("[checkin] admin save failed", err.message);
}
}
};
const checkout = () => {
if (!inService) return;
const finalPrice = price ? Number(price.replace(/\D/g, '')) : undefined;
setStore(s => ({
...s,
active: s.active.filter(a => a.id !== inService.id),
completed: [...s.completed, {
...inService,
completedAt: Date.now(),
...(finalPrice ? { finalPrice } : {}),
...(note.trim() ? { note: note.trim() } : {}),
}],
}));
setDone(true);
};
const STATUS_CHIP = {
scheduled: { label: "Pendiente", bg: "rgba(138,176,255,0.1)", color: "#8ab0ff", border: "rgba(138,176,255,0.3)" },
waiting: { label: "En sala", bg: "rgba(194,158,102,0.12)", color: "#C29E66", border: "rgba(194,158,102,0.4)" },
"in-service":{ label: "En silla", bg: "rgba(102,196,153,0.12)", color: "#66C499", border: "rgba(102,196,153,0.4)" },
completed: { label: "Completada", bg: "rgba(102,196,153,0.06)", color: "#66C499", border: "rgba(102,196,153,0.2)" },
"no-show": { label: "Incumplida", bg: "rgba(196,102,102,0.12)", color: "#e07070", border: "rgba(196,102,102,0.3)" },
cancelled: { label: "Cancelada", bg: "rgba(245,241,234,0.04)", color: "rgba(245,241,234,0.35)", border: "rgba(245,241,234,0.1)" },
};
// ── Checkout overlay ──────────────────────────────────────
if (checkoutId) {
return (
{ setCheckoutId(null); setDone(false); setPrice(''); setNote(''); }}
style={{ background: "none", border: "none", color: "#F5F1EA", cursor: "pointer",
fontFamily: "'Outfit', sans-serif", fontSize: 12, letterSpacing: "0.15em",
textTransform: "uppercase", opacity: 0.6 }}>← Volver
} />
}>
{done ? (
<>
✓
Servicio completado
Cerrado.
El cliente fue movido al historial.
>
) : !inService ? (
<>
Cita no activa.
Esta cita no está en cola activa. Actívala desde recepción primero.
>
) : (
<>
{inService.code}
{inService.name}
{[["Servicio", inService.service], ["Estilista", inService.stylist],
["Teléfono", inService.phone], ["Cédula", inService.cedula]].map(([label, val]) => (
))}
setPrice(e.target.value.replace(/\D/g, ''))}
placeholder="85.000"
style={{ width: "100%", background: "#141212",
border: "1px solid rgba(245,241,234,0.12)",
color: "#F5F1EA", padding: "14px 16px",
fontFamily: "'JetBrains Mono', monospace", fontSize: 16,
letterSpacing: "0.08em" }} />
>
)}
);
}
// ── Agenda del día ────────────────────────────────────────
return (
}>
{/* Modal de confirmación */}
{confirm && (
{ if (e.target === e.currentTarget) setConfirm(null); }}
style={{
position: "fixed", inset: 0, background: "rgba(12,12,12,0.85)",
display: "flex", alignItems: "center", justifyContent: "center",
zIndex: 100, padding: 24,
}}>
{confirm.action === 'noshow' ? (
<>
Marcar incumplida
{confirm.appt.name}
Esto registrará la cita como incumplida
{adminState.noShowFine?.enabled ? " y aplicará la multa configurada" : ""}.
Esta acción no se puede deshacer desde aquí.
>
) : (
<>
Check-in manual
{confirm.appt.name}
Confirmarás la asistencia de este cliente manualmente. Esto evita la multa por no-show.
>
)}
)}
{new Date().toLocaleDateString('es-CO', { weekday: 'long', day: 'numeric', month: 'long' })}
{todayAppts.length === 0 ? (
—
Sin citas para hoy{employee ? ` · ${employee.name}` : ""}
) : (
{todayAppts.map(appt => {
const chip = STATUS_CHIP[appt.computedStatus] || STATUS_CHIP.scheduled;
const done = ["completed", "no-show", "cancelled"].includes(appt.computedStatus);
const isActive = activeIds.has(appt.id);
const canCheckIn = !done && !appt.checkedIn && appt.computedStatus !== "no-show";
const canNoShow = !done && !appt.checkedIn;
return (
{/* Hora */}
{appt.time || "—"}
{/* Info cliente */}
{appt.name}
{appt.checkedIn && (
✓ Check-in
)}
{appt.service}
{chip.label}
{!employee && (
{appt.stylist}
)}
{/* Acciones */}
{isActive && (
)}
{canCheckIn && (
)}
{canNoShow && (
)}
);
})}
)}
);
};
// ── Vista cliente / estilista / anónimo ──────────────────────
const CheckInClientView = ({ store, setStore, employee, headerRight }) => {
const [cedula, setCedula] = React.useState('');
const [confirmed, setConfirmed] = React.useState(null);
const [notFound, setNotFound] = React.useState(false);
const confirmVisit = () => {
const clean = cedula.replace(/\D/g, '');
if (clean.length < 5) return;
const today = todayStr();
const admin = loadAdminCI();
const noShowIds = admin.noShowIds || [];
const cancelledIds = admin.cancelledIds || [];
// Todas las citas válidas del cliente hoy en esta silla
const candidates = store.appointments.filter(a =>
a.cedula === clean &&
a.date === today &&
(!employee || a.stylist === employee.name) &&
!noShowIds.includes(a.id) &&
!cancelledIds.includes(a.id) &&
a.status !== 'cancelled' &&
!isPendingExpired(a)
);
if (candidates.length === 0) { setNotFound(true); return; }
// Selecciona la más próxima vigente: primero las futuras ordenadas por hora, luego las pasadas
const nowMin = timeToMin(nowCOT().toTimeString().slice(0, 5));
const upcoming = candidates.filter(a => timeToMin(a.time) >= nowMin)
.sort((a, b) => timeToMin(a.time) - timeToMin(b.time));
const appt = upcoming.length > 0
? upcoming[0]
: candidates.sort((a, b) => timeToMin(b.time) - timeToMin(a.time))[0];
setStore(s => ({
...s,
appointments: s.appointments.map(a =>
a.id === appt.id ? { ...a, checkedIn: true, checkedInAt: Date.now() } : a
),
}));
setConfirmed(appt);
setNotFound(false);
};
return (
}>
{confirmed ? (
✓
Asistencia confirmada
{confirmed.name}
{employee && (
Silla de {employee.name}
)}
{confirmed.code} · {confirmed.service}
Tu visita ha sido registrada. ¡Disfruta tu servicio!
) : (
{employee && (
Silla de {employee.name} · {employee.role}
)}
{employee ? `Bienvenido a la silla de ${employee.name}.` : "Confirma tu visita."}
Ingresa tu cédula para confirmar tu asistencia al servicio de hoy.
{ setCedula(e.target.value.replace(/\D/g, '').slice(0, 12)); setNotFound(false); }}
onKeyDown={e => e.key === 'Enter' && confirmVisit()}
placeholder="1234567890"
aria-invalid={notFound}
aria-describedby={notFound ? "checkin-cedula-err" : undefined}
autoFocus
style={{ width: "100%", background: "#0C0C0C",
border: `1px solid ${notFound ? "#C46666" : "rgba(245,241,234,0.18)"}`,
color: "#F5F1EA", padding: "18px 20px",
fontFamily: "'JetBrains Mono', monospace", fontSize: 20,
letterSpacing: "0.1em", marginBottom: notFound ? 10 : 20 }}
/>
{notFound && (
No encontramos una cita para hoy con esa cédula
{employee ? ` en la silla de ${employee.name}` : ""}.
)}
{employee ? `QR exclusivo · silla de ${employee.name}` : "Escanea el QR de tu silla para registrar tu visita"}
)}
);
};
// ——————————————————————————————————————————————
// WHATSAPP BLOB
// ——————————————————————————————————————————————
const WABlob = () => {
const [hovered, setHovered] = React.useState(false);
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
position: "fixed", bottom: 28, right: 28, zIndex: 9999,
display: "flex", alignItems: "center", gap: 10,
background: "#25D366", color: "#fff",
borderRadius: 999, textDecoration: "none",
padding: hovered ? "14px 22px 14px 18px" : "14px",
boxShadow: "0 4px 24px rgba(37,211,102,0.35)",
transition: "all 0.25s cubic-bezier(.4,0,.2,1)",
overflow: "hidden", whiteSpace: "nowrap",
}}
>
{hovered && (
Escríbenos
)}
);
};
// ============================================================
// AGENDA — Confirmación de citas + Resumen por empleado
// ============================================================
const AGENDA_SES = "joxe_agenda_session"; // { id, name, role }
const DAYS_ES = { dom: "Domingo", lun: "Lunes", mar: "Martes", mie: "Miércoles", jue: "Jueves", vie: "Viernes", sab: "Sábado" };
const fmtCOPAmt = n => "$" + Math.round(n || 0).toLocaleString("es-CO");
const AgendaPortal = () => {
const [store, setStore] = useStore();
// Auto-restore session from sessionStorage (same key used by Portal.html login)
const [session, setSession] = React.useState(() => {
try {
const s = JSON.parse(sessionStorage.getItem(AGENDA_SES));
return s?.id ? s : null;
} catch { return null; }
});
const [empList, setEmpList] = React.useState([]);
const [selId, setSelId] = React.useState("");
const [pin, setPin] = React.useState("");
const [err, setErr] = React.useState("");
const [confirmErr, setConfirmErr] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [confirming, setConfirming] = React.useState(null);
const [confirmed, setConfirmed] = React.useState(null);
// PIN prompt shown when session was auto-restored (pin not in memory)
const [pinPrompt, setPinPrompt] = React.useState(null);
const [pinInput, setPinInput] = React.useState("");
// Tabs + resumen
const [view, setView] = React.useState("agenda");
const [summaryData, setSummaryData] = React.useState(null);
const [loadingSummary, setLoadingSummary] = React.useState(false);
const [summaryErr, setSummaryErr] = React.useState("");
const [summaryNeedPin, setSummaryNeedPin] = React.useState(false);
const [summaryPinInput, setSummaryPinInput] = React.useState("");
React.useEffect(() => {
// Pre-select employee from previous session if available
try {
const s = JSON.parse(sessionStorage.getItem(AGENDA_SES));
if (s?.id) setSelId(s.id);
} catch {}
}, []);
React.useEffect(() => {
fetch("/api/catalog")
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.employees) setEmpList(d.employees); })
.catch(() => {});
}, []);
const login = async () => {
if (!selId || !pin) return;
setLoading(true); setErr("");
try {
const res = await fetch("/api/agenda", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "login", empId: selId, pin }),
});
const data = await res.json();
if (!res.ok) { setErr(data.error || "PIN incorrecto"); setPin(""); setLoading(false); return; }
sessionStorage.setItem(AGENDA_SES, JSON.stringify(data.employee));
if (data.token) sessionStorage.setItem("joxe_emp_token", data.token);
setSession(data.employee);
// pin stays in state for confirm calls during this session
} catch { setErr("Error de conexión"); }
setLoading(false);
};
const confirmAppt = async (appt, pinOverride) => {
const usedPin = pinOverride ?? pin;
if (!usedPin) {
// Pin not in memory (session restored from Portal.html) — ask for it
setPinPrompt(appt);
setPinInput("");
return;
}
setConfirming(appt.id);
setConfirmErr("");
setPinPrompt(null);
try {
const res = await fetch("/api/agenda", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "confirm", empId: session.id, pin: usedPin, apptId: appt.id }),
});
const data = await res.json();
if (res.ok) {
if (!pin) setPin(usedPin); // cache PIN for subsequent confirms this session
setStore(s => ({
...s,
appointments: s.appointments.map(a =>
a.id === appt.id
? { ...a, status: "scheduled", confirmedAt: Date.now(), confirmedBy: session.name }
: a
),
}));
setConfirmed(appt.id);
setTimeout(() => setConfirmed(null), 3000);
} else {
setConfirmErr(data.error || "Error al confirmar la cita.");
if (data.error?.toLowerCase().includes("pin")) setPin(""); // clear cached bad pin
}
} catch {
setConfirmErr("Error de conexión. Intenta de nuevo.");
}
setConfirming(null);
};
const logout = () => {
sessionStorage.removeItem(AGENDA_SES);
setSession(null); setPin(""); setSelId(""); setErr(""); setConfirmErr("");
setView("agenda"); setSummaryData(null);
};
const fetchSummary = React.useCallback(async (pinOverride) => {
const usedPin = pinOverride ?? pin;
if (!usedPin) { setSummaryNeedPin(true); return; }
setLoadingSummary(true); setSummaryErr("");
try {
const res = await fetch("/api/agenda", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "summary", empId: session.id, pin: usedPin }),
});
const data = await res.json();
if (!res.ok) {
setSummaryErr(data.error || "Error al cargar el resumen");
} else {
setSummaryData(data);
if (!pin) { setPin(usedPin); setSummaryNeedPin(false); setSummaryPinInput(""); }
}
} catch { setSummaryErr("Error de conexión"); }
setLoadingSummary(false);
}, [pin, session]);
// Load summary when tab is opened
React.useEffect(() => {
if (view === "resumen" && session) fetchSummary();
}, [view]);
// Auto-refresh summary every 30s while on that tab
React.useEffect(() => {
if (view !== "resumen" || !session || !pin) return;
const t = setInterval(() => fetchSummary(), 30000);
return () => clearInterval(t);
}, [view, session, pin]);
const fmtDate = (d) => {
if (!d) return "—";
try { return new Date(d + "T12:00").toLocaleDateString("es-CO", { weekday: "short", day: "numeric", month: "short" }); }
catch { return d; }
};
const today = new Date().toISOString().slice(0, 10);
// Citas pendientes del empleado logueado (todas las fechas — para confirmar)
const myPending = React.useMemo(() => {
if (!session) return [];
return (store.appointments || []).filter(
a => a.stylist === session.name && a.status === "pending"
).sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time));
}, [store.appointments, session]);
// Datos en vivo para la vista Resumen (desde el store que se actualiza cada 5s)
const myTodayScheduled = React.useMemo(() => {
if (!session) return [];
return (store.appointments || []).filter(
a => a.stylist === session.name && a.date === today &&
(a.status === "scheduled" || a.status === "confirmed")
).sort((a, b) => a.time.localeCompare(b.time));
}, [store.appointments, session, today]);
const myTodayPending = React.useMemo(() => {
if (!session) return [];
return (store.appointments || []).filter(
a => a.stylist === session.name && a.date === today && a.status === "pending"
).sort((a, b) => a.time.localeCompare(b.time));
}, [store.appointments, session, today]);
// ── LOGIN ──
if (!session) {
return (
}>
Selecciona tu nombre
{empList.length === 0 && (
Cargando empleados…
)}
{empList.map(e => (
))}
{selId && (
{ setPin(e.target.value.replace(/\D/g, "")); setErr(""); }}
onKeyDown={e => e.key === "Enter" && pin && login()}
aria-label="PIN de empleado"
placeholder="Ingresa tu PIN"
style={{
width: "100%", padding: "18px", textAlign: "center",
fontFamily: "'JetBrains Mono', monospace", fontSize: 28,
letterSpacing: "0.5em", background: "rgba(245,241,234,0.05)",
border: "1px solid rgba(245,241,234,0.15)", color: "#F5F1EA",
boxSizing: "border-box",
}}
/>
)}
{err && (
{err}
)}
);
}
// ── TAB BAR ──
const tabBar = (
{[["agenda", "Mi Agenda"], ["resumen", "Resumen"]].map(([v, label]) => (
))}
);
// ── RESUMEN VIEW ──
if (view === "resumen") {
const sd = summaryData;
// Revenue breakdown by method
const byMethod = {};
(sd?.revenueEntries || []).forEach(r => {
byMethod[r.method] = (byMethod[r.method] || 0) + r.amount;
});
// Work hours
const wh = sd?.workHours;
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
const ApptRowSimple = ({ a, badge, badgeColor }) => (
{a.time}
{a.name || "—"}
{a.service}
{badge && (
{badge}
)}
);
const SectionHead = ({ label, count }) => (
{label}{count !== undefined ? ` · ${count}` : ""}
);
const empty = (msg) => (
);
return (
Salir
}
/>
}>
{tabBar}
{/* PIN needed for session restored without pin */}
{summaryNeedPin && (
Confirma tu PIN para ver el resumen
setSummaryPinInput(e.target.value.replace(/\D/g, ""))}
onKeyDown={e => e.key === "Enter" && summaryPinInput && fetchSummary(summaryPinInput)}
placeholder="••••"
style={{
width: "100%", padding: "16px", marginBottom: 12,
background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)",
color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace",
fontSize: 28, letterSpacing: "0.4em", textAlign: "center", boxSizing: "border-box",
}}
/>
)}
{!summaryNeedPin && summaryErr && (
{summaryErr}
)}
{!summaryNeedPin && loadingSummary && !sd && (
)}
{!summaryNeedPin && (
<>
{/* ── TOTAL DEL DÍA ── */}
{fmtCOPAmt(sd?.totalHoy || 0)}
{Object.keys(byMethod).length > 0 ? (
{Object.entries(byMethod).map(([m, amt]) => (
))}
) : (
Sin ingresos registrados hoy
)}
{/* refresh hint */}
{/* ── CONFIRMADOS HOY ── */}
{myTodayScheduled.length === 0
? empty("Sin turnos confirmados para hoy")
: myTodayScheduled.map(a => (
))
}
{/* ── POR CONFIRMAR CONSIGNACIÓN ── */}
{myTodayPending.length === 0
? empty("Sin solicitudes pendientes para hoy")
: myTodayPending.map(a => {
const isDone = confirmed === a.id;
const isLoading = confirming === a.id;
return (
{a.time}
{a.name || "—"}
{a.service}
{isDone &&
✓ Confirmado}
{!isDone && (
)}
);
})
}
{/* ── COMPLETADOS ── */}
{(() => {
const completedList = [...(sd?.active || []), ...(sd?.completed || [])];
return (
<>
{completedList.length === 0
? empty("Sin servicios completados hoy")
: completedList.sort((a, b) => (a.time || "").localeCompare(b.time || "")).map(a => (
))
}
>
);
})()}
{/* ── HORARIOS DISPONIBLES ── */}
{wh ? (
{dayOrder.map(d => {
const h = wh[d];
if (!h) return null;
return (
{DAYS_ES[d]}
{h.open} — {h.close}
);
}).filter(Boolean)}
) : (
empty("Sin horario configurado · Contacta al administrador")
)}
>
)}
{/* PIN prompt overlay — shown when session was auto-restored from Portal.html */}
{pinPrompt && (
{ if (e.target === e.currentTarget) { setPinPrompt(null); setPinInput(""); } }}
>
Confirmar identidad
{pinPrompt.name}
{pinPrompt.service} · {pinPrompt.date} {pinPrompt.time}
{ setPinInput(e.target.value.replace(/\D/g, "")); setConfirmErr(""); }}
onKeyDown={e => e.key === "Enter" && pinInput && confirmAppt(pinPrompt, pinInput)}
placeholder="••••"
style={{
width: "100%", padding: "16px", marginBottom: 16,
background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)",
color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace",
fontSize: 28, letterSpacing: "0.4em", textAlign: "center", boxSizing: "border-box",
}}
/>
{confirmErr &&
{confirmErr}
}
)}
);
}
// ── AGENDA ──
return (
Salir
}
/>
}>
{tabBar}
Solicitudes pendientes · {myPending.length}
{confirmErr && (
{confirmErr}
)}
{myPending.length === 0 ? (
—
Sin solicitudes pendientes
) : (
{myPending.map(a => {
const isDone = confirmed === a.id;
const isLoading = confirming === a.id;
return (
{fmtDate(a.date)}
{a.time}
{a.phone && (
)}
{isDone ? (
✓ Cita confirmada
) : (
)}
);
})}
)}
{/* PIN prompt overlay — shown when session was auto-restored from Portal.html */}
{pinPrompt && (
{ if (e.target === e.currentTarget) { setPinPrompt(null); setPinInput(""); } }}
>
Confirmar identidad
{pinPrompt.name}
{pinPrompt.service} · {pinPrompt.date} {pinPrompt.time}
{ setPinInput(e.target.value.replace(/\D/g, "")); setConfirmErr(""); }}
onKeyDown={e => e.key === "Enter" && pinInput && confirmAppt(pinPrompt, pinInput)}
placeholder="••••"
style={{
width: "100%", padding: "16px", marginBottom: 16,
background: "#0C0C0C", border: "1px solid rgba(245,241,234,0.15)",
color: "#F5F1EA", fontFamily: "'JetBrains Mono', monospace",
fontSize: 28, letterSpacing: "0.4em", textAlign: "center",
boxSizing: "border-box",
}}
/>
{confirmErr && (
{confirmErr}
)}
)}
);
};
Object.assign(window, {
BookingPortal, ScanPortal, LobbyPortal, HomePortal, CuentaPortal, CheckInPortal,
AgendaPortal,
QRCode, PortalShell, PortalHeader, PMono, useStore, WABlob,
Dialog, useDialog,
});