const { useState, useEffect, useRef, useMemo } = React;
// ==========================================
// COMPONENTE: VISTA DE LOGIN
// ==========================================
function LoginView({ setView, setProfile, hasUsers, allUsers }) {
const [email, setEmail] = useState('');
const [pass, setPass] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const isSetup = !hasUsers;
const handleLogin = async (e) => {
e.preventDefault();
if (isSetup) {
const admin = {
name,
email: email.toLowerCase().trim(),
password: pass,
role: 'admin',
status: 'activo',
hiringDate: window.getNICDate(),
vacationDaysUsed: 0,
overrideCheckOut: false
};
const ref = await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').add(admin);
const p = { id: ref.id, ...admin };
setProfile(p);
localStorage.setItem('shift_session_v4', JSON.stringify(p));
setView('admin');
} else {
const found = allUsers.find(u => u.email === email.toLowerCase().trim() && u.password === pass);
if (found) {
if (found.status === 'baja') return setError("Cuenta desactivada por administración.");
setProfile(found);
localStorage.setItem('shift_session_v4', JSON.stringify(found));
setView(found.role === 'admin' ? 'admin' : 'dashboard');
} else setError("Credenciales incorrectas.");
}
};
return (
{isSetup ? "Setup Maestro" : "Shift Marcas"}
Nicaragua (UTC-6)
);
}
// ==========================================
// COMPONENTE: DASHBOARD DEL CONSULTOR
// ==========================================
function UserDashboard({ profile, logs, reminders = [], onLogout, setView, allRoles }) {
const [status, setStatus] = useState('fuera');
const [showCam, setShowCam] = useState(false);
const [purpose, setPurpose] = useState('');
const [clientName, setClientName] = useState('');
const [loadingMark, setLoadingMark] = useState(false);
const videoRef = useRef(null);
const canvasRef = useRef(null);
const todayStr = window.getNICDate();
const todayLogs = useMemo(() => logs.filter(l => l.date === todayStr).sort((a,b) => a.timestamp - b.timestamp), [logs, todayStr]);
useEffect(() => {
if (todayLogs.length > 0) setStatus(todayLogs[todayLogs.length - 1].type);
else setStatus('fuera');
}, [todayLogs]);
// Filtrar notificaciones activas para este usuario
const activeReminders = useMemo(() => {
const now = Date.now();
return reminders.filter(r =>
r.targetUsers.includes(profile.id) &&
!r.completedBy[profile.id] &&
(!r.expiresAt || r.expiresAt > now)
);
}, [reminders, profile.id]);
const markReminderCompleted = async (rId) => {
try {
const rRef = db.collection('artifacts').doc(appId).collection('public').doc('data').collection('reminders').doc(rId);
const rDoc = await rRef.get();
const completedBy = rDoc.data().completedBy || {};
completedBy[profile.id] = Date.now();
await rRef.update({ completedBy });
} catch (e) { alert("Error al confirmar lectura."); }
};
// Obtener los permisos del rol actual (Soporta estructura antigua de strings)
const userRoleConfig = useMemo(() => allRoles.find(r => (r.name || r) === profile.role), [allRoles, profile.role]);
const permissions = userRoleConfig && userRoleConfig.permissions ? userRoleConfig.permissions : [];
// Bloqueo de jornada si ya marcó salida (con bypass administrativo)
const isShiftCompleted = useMemo(() => {
const hasSalida = todayLogs.some(l => l.type === 'salida');
return hasSalida && !profile.overrideCheckOut;
}, [todayLogs, profile.overrideCheckOut]);
const startAction = async (p) => {
if (isShiftCompleted) return;
// Acciones sin cámara (configurables)
if (['almuerzo', 'vuelta_almuerzo', 'break', 'capacitacion', 'reunion', 'gestiones'].includes(p)) {
return saveMark(p, null, null);
}
if (p === 'visita_in') {
const name = prompt("Escriba el nombre del Cliente / Referencia:");
if (!name) return;
setClientName(name);
}
setPurpose(p);
setShowCam(true);
const useRear = p.includes('visita');
const constraints = { video: { facingMode: useRear ? 'environment' : 'user', width: { ideal: 1280 }, height: { ideal: 720 } } };
try {
const s = await navigator.mediaDevices.getUserMedia(constraints);
if (videoRef.current) {
videoRef.current.srcObject = s;
if (!useRear) videoRef.current.classList.add('mirror');
else videoRef.current.classList.remove('mirror');
}
} catch (err) { alert("Error al activar cámara."); setShowCam(false); }
};
const capture = () => {
const canvas = canvasRef.current;
const video = videoRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!purpose.includes('visita')) { ctx.translate(canvas.width, 0); ctx.scale(-1, 1); }
ctx.drawImage(video, 0, 0);
const photo = canvas.toDataURL('image/jpeg', 0.6);
navigator.geolocation.getCurrentPosition(
(pos) => saveMark(purpose, photo, { lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => { alert("GPS obligatorio para auditar ubicación."); setShowCam(false); },
{ enableHighAccuracy: true }
);
};
const saveMark = async (type, photo, loc) => {
setLoadingMark(true);
try {
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('logs').add({
userId: profile.id,
userName: profile.name,
type,
timestamp: Date.now(),
location: loc,
photo,
client: clientName,
date: todayStr,
role: profile.role
});
// Si es salida, reseteamos el desbloqueo administrativo para el día siguiente
if (type === 'salida' && profile.overrideCheckOut) {
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').doc(profile.id).update({ overrideCheckOut: false });
}
if (videoRef.current?.srcObject) videoRef.current.srcObject.getTracks().forEach(t => t.stop());
setShowCam(false);
setClientName('');
} catch (e) { alert("Error de red al guardar marca."); }
finally { setLoadingMark(false); }
};
if (profile.status === 'subsidio' || profile.status === 'feriado') return (
Estado: {profile.status}
No habilitado para marcas hoy
);
return (
{/* ALERTAS/NOTIFICACIONES PENDIENTES */}
{activeReminders.length > 0 && (
{activeReminders.map(r => (
{r.title}
{r.message}
))}
)}
{profile.name[0]}
{profile.name}
{profile.role}
{profile.role === 'admin' && (
)}
{isShiftCompleted ? (
Jornada Finalizada
Se habilitará mañana a las 12:00 AM (NI)
) : (status === 'fuera' || status === 'salida') ? (
) : (
ACTIVO
¡Que tengas un excelente día!
{/* BOTONES DINÁMICOS BASADOS EN LOS PERMISOS DEL ROL SELECCIONADO POR EL ADMIN */}
{permissions.includes('almuerzo') && (
)}
{permissions.includes('break') && (
)}
{permissions.includes('visita_in') && (
)}
{permissions.includes('reunion') && (
)}
{permissions.includes('capacitacion') && (
)}
{permissions.includes('gestiones') && (
)}
)}
Mi Actividad de Hoy
{todayLogs.length === 0 ?
No hay marcas registradas todavía
: (
{todayLogs.map(l => (
{l.type.replace('_',' ')} {l.client && @ {l.client}}
{new Date(l.timestamp).toLocaleTimeString('es-NI', {timeZone: 'America/Managua', hour:'2-digit', minute:'2-digit', hour12: true})}
))}
)}
{showCam && (
Protocolo Biométrico
)}
);
}
// ==========================================
// COMPONENTE: PANEL DE ADMINISTRACIÓN
// ==========================================
function AdminDashboard({ globalUsers, logs, roles, reminders = [], setView, onLogout }) {
const [tab, setTab] = useState('logs');
const [searchName, setSearchName] = useState('');
const [searchDate, setSearchDate] = useState(window.getNICDate());
const [editingUser, setEditingUser] = useState(null);
const [editingLog, setEditingLog] = useState(null);
const [userToDelete, setUserToDelete] = useState(null);
const [submitError, setSubmitError] = useState('');
// Estados para Roles
const [newRoleName, setNewRoleName] = useState('');
const [selectedPermissions, setSelectedPermissions] = useState([]);
const availableActions = [
{ id: 'almuerzo', label: 'Almuerzo' },
{ id: 'break', label: 'Break' },
{ id: 'visita_in', label: 'Visita Cliente' },
{ id: 'reunion', label: 'Reunión' },
{ id: 'capacitacion', label: 'Capacitación' },
{ id: 'gestiones', label: 'Gestiones' }
];
// Estados para Notificaciones
const [notifTitle, setNotifTitle] = useState('');
const [notifBody, setNotifBody] = useState('');
const [notifExpiry, setNotifExpiry] = useState('');
const [targetUsers, setTargetUsers] = useState([]);
const filteredLogs = useMemo(() => logs.filter(l => {
const matchName = l.userName.toLowerCase().includes(searchName.toLowerCase());
const matchDate = searchDate ? l.date === searchDate : true;
return matchName && matchDate;
}).sort((a,b) => b.timestamp - a.timestamp), [logs, searchName, searchDate]);
const handleUserSubmit = async (e) => {
e.preventDefault();
setSubmitError('');
const f = new FormData(e.target);
const email = f.get('email').toLowerCase().trim();
// VALIDACIÓN DE DUPLICIDAD
if (!editingUser) {
const existing = globalUsers.find(u => u.email === email);
if (existing) {
setSubmitError(`ADVERTENCIA: Correo ya asignado a ${existing.name} (${existing.status.toUpperCase()})`);
return;
}
}
const data = {
name: f.get('name'), email,
password: f.get('pass'), role: f.get('role'), status: f.get('status'),
hiringDate: f.get('date'), vacationDaysUsed: parseInt(f.get('vused')) || 0,
vacationStart: f.get('vs') || '', vacationEnd: f.get('ve') || '',
overrideCheckOut: editingUser ? (editingUser.overrideCheckOut || false) : false
};
try {
if(editingUser) await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').doc(editingUser.id).update(data);
else await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').add(data);
setEditingUser(null);
} catch (err) { setSubmitError("Error al guardar datos."); }
};
const deleteUser = async () => {
if (!userToDelete) return;
try {
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').doc(userToDelete.id).delete();
setUserToDelete(null);
} catch (err) { alert("Error al eliminar."); }
};
const unlockUser = async (uId) => {
try {
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').doc(uId).update({ overrideCheckOut: true });
alert("Usuario habilitado para re-entrar hoy.");
} catch (err) { alert("Error al desbloquear."); }
};
const updateLog = async (e) => {
e.preventDefault();
const f = new FormData(e.target);
const newTimeStr = f.get('ntime');
const newTimestamp = new Date(newTimeStr).getTime();
const newDate = newTimeStr.split('T')[0];
try {
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('logs').doc(editingLog.id).update({
timestamp: newTimestamp, type: f.get('ntype'), client: f.get('nclient'), edited: true, date: newDate
});
setEditingLog(null);
} catch (err) { alert("Error al actualizar marca."); }
};
const togglePermission = (id) => {
setSelectedPermissions(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
};
const addRole = async () => {
if(!newRoleName) return;
try {
const newRoleObj = { name: newRoleName.toLowerCase().trim(), permissions: selectedPermissions };
// Filtrar tanto objetos como strings antiguos para evitar errores
const updated = [...roles.filter(r => (r.name || r) !== newRoleObj.name), newRoleObj];
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('settings').doc('roles').set({ list: updated });
setNewRoleName('');
setSelectedPermissions([]);
} catch (err) { alert("Error al guardar rol."); }
};
const sendNotification = async () => {
if (!notifTitle || targetUsers.length === 0) return alert("Completa el título y elige al menos un usuario.");
try {
await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('reminders').add({
title: notifTitle,
message: notifBody,
expiresAt: notifExpiry ? new Date(notifExpiry).getTime() : null,
targetUsers: targetUsers,
completedBy: {},
createdAt: Date.now(),
createdBy: 'Admin'
});
setNotifTitle(''); setNotifBody(''); setNotifExpiry(''); setTargetUsers([]);
alert("Notificación enviada correctamente.");
} catch (e) { alert("Error al enviar."); }
};
return (
{tab === 'logs' && (
| Colaborador | Marca | Fecha/Hora (12h) | Auditoría | Acción |
{filteredLogs.map(l => (
|
|
{l.type.replace('_',' ')} {l.client && `(${l.client})`}
|
{new Date(l.timestamp).toLocaleString('es-NI', {timeZone: 'America/Managua', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true})}
{l.edited && Editado por Admin }
|
{l.photo && }
{l.location && }
|
|
))}
)}
{tab === 'users' && (
{editingUser ? "Editar Perfil" : "Nuevo Registro"}
{globalUsers.map(u => (
{u.name[0]}
{u.name}
{u.role} • {u.status}
{u.overrideCheckOut && (DESBLOQUEADO)}
))}
)}
{tab === 'roles' && (
Configurar Rol y Permisos
setNewRoleName(e.target.value)} placeholder="Nombre del Rol (ej: Ventas)" className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none font-bold uppercase focus:border-indigo-400" />
Habilitar botones para este rol:
{availableActions.map(action => (
))}
{roles.map(r => {
const rName = r.name || r;
const rPerms = r.permissions || [];
return (
{rName}
{rPerms.map(p => {p})}
{rPerms.length === 0 && Sin permisos adicionales}
)
})}
)}
{/* TAB: NOTIFICACIONES */}
{tab === 'notif' && (
Historial de Avisos
{reminders.sort((a,b)=>b.createdAt-a.createdAt).map(r => (
{r.title}
{r.message}
Enviado: {new Date(r.createdAt).toLocaleString('es-NI', {timeZone: 'America/Managua'})}
{r.expiresAt && Expira: {new Date(r.expiresAt).toLocaleString('es-NI', {timeZone: 'America/Managua'})}}
Confirmaciones de lectura:
{Object.entries(r.completedBy || {}).map(([uId, ts]) => (
{globalUsers.find(u=>u.id===uId)?.name || 'Usuario'} - {new Date(ts).toLocaleTimeString('es-NI', {timeZone: 'America/Managua', hour:'2-digit', minute:'2-digit', hour12:true})}
))}
{Object.keys(r.completedBy || {}).length === 0 &&
Nadie ha completado este aviso.}
))}
)}
{/* MODALES DE CONFIRMACIÓN */}
{userToDelete && (
¿Eliminar Usuario?
Estás a punto de borrar a {userToDelete.name}. Esta acción es permanente y no se puede deshacer.
)}
{editingLog && (
)}
);
}
// ==========================================
// COMPONENTE: APP PRINCIPAL (ORQUESTADOR)
// ==========================================
function App() {
const [user, setUser] = useState(null);
const [profile, setProfile] = useState(null);
const [view, setView] = useState('login');
const [loading, setLoading] = useState(true);
const [globalUsers, setGlobalUsers] = useState([]);
const [logs, setLogs] = useState([]);
const [roles, setRoles] = useState([]);
const [reminders, setReminders] = useState([]);
useEffect(() => {
const unsubAuth = auth.onAuthStateChanged(async (u) => {
if (u) {
setUser(u);
const saved = localStorage.getItem('shift_session_v4');
if (saved) {
try {
const parsed = JSON.parse(saved);
const uDoc = await db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').doc(parsed.id).get();
if (uDoc.exists && uDoc.data().status !== 'baja') {
setProfile({ id: uDoc.id, ...uDoc.data() });
if (uDoc.data().role === 'admin' && view === 'login') setView('admin');
else if (uDoc.data().role !== 'admin') setView('dashboard');
} else { setView('login'); }
} catch (e) { setView('login'); }
}
} else { auth.signInAnonymously().catch(console.error); }
setLoading(false);
});
return () => unsubAuth();
}, []);
useEffect(() => {
if (!user) return;
const unsubUsers = db.collection('artifacts').doc(appId).collection('public').doc('data').collection('users').onSnapshot(s => setGlobalUsers(s.docs.map(d=>({id:d.id, ...d.data()}))));
const unsubLogs = db.collection('artifacts').doc(appId).collection('public').doc('data').collection('logs').onSnapshot(s => setLogs(s.docs.map(d=>({id:d.id, ...d.data()}))));
// Maneja la conversión de roles antiguos (strings) y roles nuevos (objetos con permisos)
const unsubRoles = db.collection('artifacts').doc(appId).collection('public').doc('data').collection('settings').doc('roles').onSnapshot(s => {
if(s.exists) setRoles(s.data().list || []);
else setRoles([]);
});
// Suscripción de Recordatorios
const unsubReminders = db.collection('artifacts').doc(appId).collection('public').doc('data').collection('reminders').onSnapshot(s => {
setReminders(s.docs.map(d => ({id: d.id, ...d.data()})));
});
return () => { unsubUsers(); unsubLogs(); unsubRoles(); unsubReminders(); };
}, [user]);
if (loading) return (
Iniciando Shift Marcas Nicaragua...
);
return (
{view === 'login' &&
0} allUsers={globalUsers} />}
{view === 'dashboard' && profile && (
l.userId === profile.id)}
reminders={reminders}
onLogout={() => { localStorage.removeItem('shift_session_v4'); setProfile(null); setView('login'); }}
setView={setView}
allRoles={roles}
/>
)}
{view === 'admin' && profile?.role === 'admin' && (
{ localStorage.removeItem('shift_session_v4'); setProfile(null); setView('login'); }}
/>
)}
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();