// ============ PRISMA · PANEL CLIENTE — app shell ============ const { useState, useEffect, useRef, useMemo } = React; const clClone = (o) => JSON.parse(JSON.stringify(o)); const ACCENT_KEYS = ['magenta', 'azul', 'indigo', 'teal']; // ---- mappers ---- function mapBuilding(row, i) { const b = row.edificios || {}; return { id: row.edificio_id, slug: b.slug || '', name: b.nombre || '—', accent: ACCENT_KEYS[i % ACCENT_KEYS.length], address: b.ciudad || '', company: '', // se llena desde el perfil del usuario si hace falta }; } function sortFloor(label) { if (!label) return 999; const l = label.toUpperCase(); if (l === 'PB') return 0; if (l === 'PA') return -1; const n = parseInt(l.replace(/\D/g, ''), 10); return isNaN(n) ? 999 : n; } function buildType(u) { const parts = []; if (u.dorm !== null && u.dorm !== undefined) { parts.push(u.dorm === 0 ? 'Monoamb.' : u.dorm + ' dorm'); } if (u.tipo) parts.push(u.tipo); return parts.join(' · ') || '—'; } // Carga disponibilidad de un edificio desde Supabase async function loadBuildingAvail(buildingId) { const { data, error } = await window._sb .from('unidades') .select('id, title, tipo, dorm, disponibilidad(id, label, status)') .eq('edificio_id', buildingId) .order('title'); if (error || !data) return []; return data.map(u => ({ id: u.id, name: u.title || '—', type: buildType(u), floors: (u.disponibilidad || []) .slice() .sort((a, b) => sortFloor(a.label) - sortFloor(b.label)) .map(d => ({ dispId: d.id, label: d.label, status: window.DB_TO_DISPLAY[d.status] || 'disponible', })), })); } // ---- helpers mobile ---- const useIsMobile = () => { const [mobile, setMobile] = React.useState(() => window.innerWidth <= 680); React.useEffect(() => { const fn = () => setMobile(window.innerWidth <= 680); window.addEventListener('resize', fn); return () => window.removeEventListener('resize', fn); }, []); return mobile; }; const CiMenu = (p) => ( ); // ---- CL_NAV ---- const CL_NAV = [ { id: 'dashboard', label: 'Dashboard', icon: window.CiDashboard }, { id: 'disponibilidad', label: 'Disponibilidad', icon: window.CiAvail }, { id: 'metricas', label: 'Métricas', icon: window.CiChart }, ]; const CL_TITLES = { dashboard: 'Dashboard', disponibilidad: 'Disponibilidad', metricas: 'Métricas' }; // ---- Building selector ---- const ClBuildingSelector = ({ active, buildings, onPick }) => { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const fn = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', fn); return () => document.removeEventListener('mousedown', fn); }, []); const ac = window.acHex(active.accent); return (
{open && (
Tus edificios
{buildings.map(b => { const isA = b.id === active.id; const bac = window.acHex(b.accent); return ( ); })}
)}
); }; // ---- Sidebar ---- const ClSidebar = ({ screen, setScreen, active, buildings, userName, onPickBuilding, onLogout, open, onClose }) => { const initials = userName ? userName.slice(0, 2).toUpperCase() : 'PS'; const isMobile = useIsMobile(); if (isMobile && !open) return null; return ( <> {isMobile &&
} ); }; // ---- Topbar ---- const ClTopbar = ({ screen, active, onMenuClick }) => { const ac = window.acHex(active.accent); const isMobile = useIsMobile(); const showroomUrl = active.slug ? `https://${active.slug.replace(/-/g, '')}.prismastudios3d.net` : null; return (
{isMobile && ( )}
{CL_TITLES[screen]}
{active.name} {showroomUrl && !isMobile && ( { e.currentTarget.style.color = 'var(--txt)'; e.currentTarget.style.borderColor = 'var(--line-strong)'; }} onMouseLeave={e => { e.currentTarget.style.color = 'var(--txt-mut)'; e.currentTarget.style.borderColor = 'var(--line)'; }}> Ver showroom )}
); }; // ---- Save Banner ---- const ClSaveBanner = ({ count, justSaved, onSave, onDiscard, saving, error }) => { if (!count && !justSaved && !error) return null; const isError = !!error; const bg = isError ? window.hexA('#EF4444', 0.08) : justSaved ? window.hexA('#22C55E', 0.08) : 'var(--bg-2)'; const border = isError ? window.hexA('#EF4444', 0.4) : justSaved ? window.hexA('#22C55E', 0.4) : 'var(--line-strong)'; return (
{isError ? ( <> {error} ) : justSaved ? ( <> Cambios guardados ) : ( <> {count} {count === 1 ? 'cambio sin guardar' : 'cambios sin guardar'} )}
); }; // ---- Switch-building confirm modal ---- const ClSwitchConfirm = ({ fromName, onCancel, onConfirm }) => (
{ if (e.target === e.currentTarget) onCancel(); }}>
Cambios sin guardar

Hiciste cambios en {fromName} que todavía no guardaste. Si cambiás de edificio, esos cambios se descartan.

); // ---- Boot spinner ---- const ClBootSpinner = () => (
Prisma
); // ---- Loading panel ---- const ClLoadingPanel = () => (
Cargando unidades…
); // ---- No buildings ---- const ClNoBuildings = ({ onLogout }) => (
Sin edificios asignados
Tu cuenta no tiene edificios vinculados. Contactá a Prisma Studios.
); // ---- App root ---- const ClApp = () => { const [booting, setBooting] = useState(true); const [logged, setLogged] = useState(false); const [loginError, setLoginError] = useState(''); const [user, setUser] = useState(null); const [buildings, setBuildings] = useState([]); const [activeId, setActiveId] = useState(null); const [screen, setScreen] = useState(() => localStorage.getItem('cl_screen') || 'dashboard'); const [sidebarOpen, setSidebarOpen] = useState(false); const [period, setPeriod] = useState('hoy'); // disponibilidad const [work, setWork] = useState({}); // { buildingId: rows[] } const [saved, setSaved] = useState({}); // { buildingId: rows[] } const [loadingAvail, setLoadingAvail] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); const [justSaved, setJustSaved] = useState(false); const [pendingSwitch, setPendingSwitch] = useState(null); const scrollRef = useRef(null); // persistir sección activa useEffect(() => { localStorage.setItem('cl_screen', screen); }, [screen]); // reset scroll al cambiar sección o edificio useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [screen, activeId]); // verificar sesión al arrancar useEffect(() => { window._sb.auth.getSession().then(({ data: { session } }) => { if (session) { setUser(session.user); doLoadBuildings(session.user.id).then(bs => { if (bs.length) setActiveId(bs[0].id); setLogged(true); }); } else { setBooting(false); } }); }, []); const doLoadBuildings = async (userId) => { const { data } = await window._sb .from('usuarios_clientes') .select('edificio_id, edificios(id, slug, nombre, ciudad)') .eq('user_id', userId); const bs = (data || []).map(mapBuilding); setBuildings(bs); setBooting(false); return bs; }; // cargar disponibilidad de un edificio (lazy, una sola vez por sesión) const ensureAvail = async (buildingId) => { if (work[buildingId] !== undefined) return; setLoadingAvail(true); const rows = await loadBuildingAvail(buildingId); setWork(prev => ({ ...prev, [buildingId]: rows })); setSaved(prev => ({ ...prev, [buildingId]: clClone(rows) })); setLoadingAvail(false); }; // precargar disponibilidad apenas hay edificio activo (para que Dashboard muestre contadores) useEffect(() => { if (activeId) ensureAvail(activeId); }, [activeId]); const handleLogin = async (email, password) => { setLoginError(''); const { data, error } = await window._sb.auth.signInWithPassword({ email, password }); if (error) { setLoginError(error.message); return; } setUser(data.user); const bs = await doLoadBuildings(data.user.id); if (bs.length) setActiveId(bs[0].id); setLogged(true); }; const handleLogout = async () => { await window._sb.auth.signOut(); setLogged(false); setUser(null); setBuildings([]); setActiveId(null); setWork({}); setSaved({}); setScreen('dashboard'); setLoginError(''); }; // rotar estado de un chip const handleCycle = (ri, fi) => { setJustSaved(false); setWork(prev => { const next = clClone(prev); const cur = next[activeId][ri].floors[fi].status; const idx = window.CL_STATUS_CYCLE.indexOf(cur); next[activeId][ri].floors[fi].status = window.CL_STATUS_CYCLE[(idx + 1) % window.CL_STATUS_CYCLE.length]; return next; }); }; // cantidad de cambios pendientes const pendingCount = useMemo(() => { if (!work[activeId] || !saved[activeId]) return 0; let n = 0; work[activeId].forEach((row, ri) => { (row.floors || []).forEach((f, fi) => { const s = saved[activeId][ri]; if (s && f.status !== s.floors[fi]?.status) n++; }); }); return n; }, [work, saved, activeId]); // batch save: solo filas modificadas const handleSave = async () => { if (!work[activeId] || !saved[activeId]) return; setSaving(true); setSaveError(''); const now = new Date().toISOString(); const promises = []; work[activeId].forEach((row, ri) => { (row.floors || []).forEach((f, fi) => { const s = saved[activeId][ri]; if (s && f.status !== s.floors[fi]?.status) { promises.push( window._sb.from('disponibilidad') .update({ status: window.DISPLAY_TO_DB[f.status], updated_at: now }) .eq('id', f.dispId) ); } }); }); const results = await Promise.all(promises); const failed = results.filter(r => r.error); setSaving(false); if (failed.length) { setSaveError('Error al guardar ' + failed.length + ' cambio(s). Verificá tu conexión y reintentá.'); return; } setSaved(prev => ({ ...prev, [activeId]: clClone(work[activeId]) })); setJustSaved(true); setTimeout(() => setJustSaved(false), 2200); }; // forzar recarga de disponibilidad (botón Recargar) const handleRefreshAvail = async () => { if (!activeId) return; setLoadingAvail(true); setSaveError(''); const rows = await loadBuildingAvail(activeId); setWork(prev => ({ ...prev, [activeId]: rows })); setSaved(prev => ({ ...prev, [activeId]: clClone(rows) })); setLoadingAvail(false); }; const handleDiscard = () => { if (!saved[activeId]) return; setWork(prev => ({ ...prev, [activeId]: clClone(saved[activeId]) })); setJustSaved(false); }; // solicitar cambio de edificio (puede tener cambios pendientes) const requestSwitch = (id) => { if (id === activeId) return; if (pendingCount > 0) { setPendingSwitch(id); return; } setActiveId(id); }; const confirmSwitch = () => { handleDiscard(); const id = pendingSwitch; setActiveId(id); setPendingSwitch(null); setJustSaved(false); if (screen === 'disponibilidad') ensureAvail(id); }; // estados de arranque if (booting) return ; if (!logged) return ; const active = buildings.find(b => b.id === activeId) || buildings[0]; if (!active) return ; const savedRows = saved[activeId] || []; const counts = window.clCountStatuses(savedRows); return (
{ setScreen(s); setSidebarOpen(false); }} active={active} buildings={buildings} userName={user?.email?.split('@')[0] || 'Cliente'} onPickBuilding={(id) => { requestSwitch(id); setSidebarOpen(false); }} onLogout={handleLogout} open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
setSidebarOpen(o => !o)} />
{screen === 'dashboard' && } {screen === 'disponibilidad' && ( loadingAvail ? : )} {screen === 'metricas' && }
{screen === 'disponibilidad' && ( )} {pendingSwitch && ( setPendingSwitch(null)} onConfirm={confirmSwitch} /> )}
); }; ReactDOM.createRoot(document.getElementById('root')).render();