// ============ 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 (
);
};
// ---- 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(); }}>
Hiciste cambios en {fromName} que todavía no guardaste.
Si cambiás de edificio, esos cambios se descartan.
);
// ---- Boot spinner ----
const ClBootSpinner = () => (
);
// ---- 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();