<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TeamFlow Pro | Gestión de Equipos</title>
<!-- Configuración para App Móvil (PWA) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#4f46e5">
<link rel="apple-touch-icon" href="https://cdn-icons-png.flaticon.com/512/2098/2098402.png">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts: Inter -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet">
<!-- React & Babel -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://unpkg.com/lucide-react@latest"></script>
<style>
body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.glass-effect {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-slide-up { animation: slideUp 0.4s ease-out forwards; }
</style>
</head>
<body class="bg-slate-50 text-slate-900">
<div id="root"></div>
<!-- Firebase SDK (ES Modules) -->
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.1.0/firebase-app.js";
import { getFirestore, collection, addDoc, onSnapshot, updateDoc, deleteDoc, doc } from "https://www.gstatic.com/firebasejs/11.1.0/firebase-firestore.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.1.0/firebase-auth.js";
window.AppFirebase = {
initializeApp, getFirestore, collection, addDoc, onSnapshot,
updateDoc, deleteDoc, doc, getAuth, signInAnonymously,
signInWithCustomToken, onAuthStateChanged
};
</script>
<script type="text/babel">
const { useState, useEffect, useMemo } = React;
const {
Plus, Search, Calendar, User, CheckCircle2,
Clock, AlertCircle, Trash2, LayoutDashboard,
ListTodo, Download, XCircle, CheckCircle, ChevronRight, Filter
} = lucideReact;
// --- Configuración e Inicialización ---
// El sistema inyecta automáticamente __firebase_config y __app_id
const firebaseConfig = JSON.parse(__firebase_config);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'teamflow-pro-prod';
const PRIORITIES = {
Crítica: { color: 'bg-rose-100 text-rose-700 border-rose-200', icon: AlertCircle },
Alta: { color: 'bg-orange-100 text-orange-700 border-orange-200', icon: AlertCircle },
Media: { color: 'bg-amber-100 text-amber-700 border-amber-200', icon: Clock },
Baja: { color: 'bg-emerald-100 text-emerald-700 border-emerald-200', icon: CheckCircle2 },
};
const STATUSES = {
'Pendiente': 'bg-slate-100 text-slate-500 border-slate-200',
'En Proceso': 'bg-indigo-100 text-indigo-700 border-indigo-200',
'Revisión': 'bg-purple-100 text-purple-700 border-purple-200',
'Completado': 'bg-teal-100 text-teal-700 border-teal-200',
};
function App() {
const [tasks, setTasks] = useState([]);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [view, setView] = useState('board');
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterPriority, setFilterPriority] = useState('Todas');
const [newTask, setNewTask] = useState({ title: '', assignee: '', priority: 'Media', status: 'Pendiente', dueDate: '', description: '' });
useEffect(() => {
const interval = setInterval(() => {
if (window.AppFirebase) {
clearInterval(interval);
initSession();
}
}, 100);
}, []);
const initSession = async () => {
const { AppFirebase: FB } = window;
const app = FB.initializeApp(firebaseConfig);
const auth = FB.getAuth(app);
const db = FB.getFirestore(app);
// Autenticación según las reglas del entorno
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await FB.signInWithCustomToken(auth, __initial_auth_token);
} else {
await FB.signInAnonymously(auth);
}
FB.onAuthStateChanged(auth, (usr) => {
setUser(usr);
if (usr) {
// Escuchar datos en tiempo real usando la ruta obligatoria
const tasksCol = FB.collection(db, 'artifacts', appId, 'public', 'data', 'tasks');
FB.onSnapshot(tasksCol, (snapshot) => {
const data = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
setTasks(data.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)));
setLoading(false);
});
}
});
};
const stats = useMemo(() => ({
total: tasks.length,
completed: tasks.filter(t => t.status === 'Completado').length,
urgent: tasks.filter(t => (t.priority === 'Crítica' || t.priority === 'Alta') && t.status !== 'Completado').length,
progress: tasks.length > 0 ? Math.round((tasks.filter(t => t.status === 'Completado').length / tasks.length) * 100) : 0
}), [tasks]);
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.assignee.toLowerCase().includes(searchTerm.toLowerCase());
const matchesPriority = filterPriority === 'Todas' || task.priority === filterPriority;
return matchesSearch && matchesPriority;
});
const addTask = async (e) => {
e.preventDefault();
if (!user) return;
const { AppFirebase: FB } = window;
const db = FB.getFirestore();
await FB.addDoc(FB.collection(db, 'artifacts', appId, 'public', 'data', 'tasks'), {
...newTask,
createdAt: Date.now(),
createdBy: user.uid
});
setNewTask({ title: '', assignee: '', priority: 'Media', status: 'Pendiente', dueDate: '', description: '' });
setIsModalOpen(false);
};
const updateStatus = async (id, newStatus) => {
const { AppFirebase: FB } = window;
const db = FB.getFirestore();
await FB.updateDoc(FB.doc(db, 'artifacts', appId, 'public', 'data', 'tasks', id), { status: newStatus });
};
const removeTask = async (id) => {
if (!confirm('¿Deseas eliminar esta tarea?')) return;
const { AppFirebase: FB } = window;
const db = FB.getFirestore();
await FB.deleteDoc(FB.doc(db, 'artifacts', appId, 'public', 'data', 'tasks', id));
};
const downloadCSV = () => {
const headers = ['ID', 'Tarea', 'Responsable', 'Prioridad', 'Estado', 'Vencimiento'];
const rows = tasks.map(t => [t.id, t.title, t.assignee, t.priority, t.status, t.dueDate]);
const csv = [headers, ...rows].map(e => e.join(",")).join("\n");
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `equipo_flow_${new Date().toLocaleDateString()}.csv`;
link.click();
};
if (loading) return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
<p className="text-slate-400 font-black text-[10px] uppercase tracking-[0.3em]">Sincronizando Oficina</p>
</div>
</div>
);
return (
<div className="min-h-screen pb-12">
{/* Navegación Superior */}
<header className="glass-effect sticky top-0 z-40 border-b border-slate-100 px-6 py-6 sm:px-12">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex items-center gap-5">
<div className="h-14 w-14 bg-indigo-600 rounded-[1.25rem] flex items-center justify-center shadow-xl shadow-indigo-200">
<LayoutDashboard className="text-white" size={28} />
</div>
<div>
<h1 className="text-2xl font-black text-slate-800 tracking-tighter">TeamFlow <span className="text-indigo-600">Pro</span></h1>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Entorno Colaborativo</p>
</div>
</div>
<div className="flex gap-2">
<button onClick={downloadCSV} className="flex-1 md:flex-none flex items-center justify-center gap-2 px-5 py-3 bg-white border border-slate-200 rounded-2xl text-slate-600 font-bold text-xs hover:bg-slate-50 transition-all">
<Download size={16} /> <span className="hidden sm:inline">CSV</span>
</button>
<button onClick={() => setIsModalOpen(true)} className="flex-1 md:flex-none flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-2xl font-bold text-xs hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100">
<Plus size={18} /> Nueva Tarea
</button>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-6 sm:px-12 py-10">
{/* Panel de Estadísticas */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
<StatCard label="Total" value={stats.total} icon={<ListTodo />} color="text-indigo-600" />
<StatCard label="Críticas" value={stats.urgent} icon={<AlertCircle />} color="text-rose-600" />
<StatCard label="Hechas" value={stats.completed} icon={<CheckCircle />} color="text-teal-600" />
<div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex flex-col justify-center">
<div className="flex justify-between items-center mb-2">
<span className="text-[9px] font-black text-slate-400 uppercase">Progreso</span>
<span className="text-xs font-black text-indigo-600">{stats.progress}%</span>
</div>
<div className="w-full bg-slate-100 h-2.5 rounded-full overflow-hidden">
<div className="bg-indigo-600 h-full transition-all duration-700" style={{ width: `${stats.progress}%` }}></div>
</div>
</div>
</section>
{/* Filtros */}
<div className="flex flex-col md:flex-row gap-4 mb-8 bg-white p-4 rounded-[2rem] border border-slate-100 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={18} />
<input type="text" placeholder="Buscar..." className="w-full pl-11 pr-4 py-3 bg-slate-50 border-none rounded-xl outline-none text-sm font-bold placeholder:text-slate-300" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
<div className="flex gap-2">
<select className="px-4 py-3 bg-slate-50 border-none rounded-xl text-[10px] font-black text-slate-500 uppercase tracking-widest outline-none cursor-pointer" value={filterPriority} onChange={e => setFilterPriority(e.target.value)}>
<option value="Todas">Prioridad: Todas</option>
{Object.keys(PRIORITIES).map(p => <option key={p} value={p}>{p}</option>)}
</select>
<div className="flex bg-slate-100 p-1 rounded-xl">
<button onClick={() => setView('board')} className={`p-2 rounded-lg transition-all ${view === 'board' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-400'}`}><LayoutDashboard size={20} /></button>
<button onClick={() => setView('list')} className={`p-2 rounded-lg transition-all ${view === 'list' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-400'}`}><ListTodo size={20} /></button>
</div>
</div>
</div>
{/* Contenido según la Vista */}
{view === 'board' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-start overflow-x-auto no-scrollbar pb-6">
{Object.keys(STATUSES).map(status => (
<div key={status} className="flex flex-col gap-5 min-w-[280px]">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] px-3 flex justify-between items-center">
{status}
<span className="bg-slate-200 text-slate-600 px-2 py-0.5 rounded-full text-[9px]">{tasks.filter(t => t.status === status).length}</span>
</h3>
<div className="flex flex-col gap-4 p-2 rounded-[2.25rem] bg-slate-100/40 border border-dashed border-slate-200/60 min-h-[400px]">
{filteredTasks.filter(t => t.status === status).map(task => (
<TaskCard key={task.id} task={task} onUpdate={updateStatus} onDelete={removeTask} />
))}
</div>
</div>
))}
</div>
) : (
<div className="bg-white rounded-[2.5rem] border border-slate-100 shadow-sm overflow-hidden animate-slide-up">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50/50 border-b border-slate-100 text-[10px] font-black text-slate-400 uppercase tracking-widest">
<tr>
<th className="px-8 py-6">Tarea</th>
<th className="px-6 py-6">Responsable</th>
<th className="px-6 py-6">Prioridad</th>
<th className="px-6 py-6">Estado</th>
<th className="px-6 py-6 text-right">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredTasks.map(task => (
<tr key={task.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-8 py-5">
<div className="font-bold text-sm text-slate-800">{task.title}</div>
<div className="text-[10px] text-slate-400 line-clamp-1">{task.description || 'Sin notas adicionales'}</div>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-2">
<div className="h-7 w-7 bg-indigo-50 rounded-full flex items-center justify-center text-[9px] font-black text-indigo-500 uppercase">{task.assignee.charAt(0)}</div>
<span className="text-xs font-bold text-slate-600">{task.assignee}</span>
</div>
</td>
<td className="px-6 py-5">
<span className={`px-2.5 py-1 rounded-full text-[9px] font-black uppercase border tracking-tight ${PRIORITIES[task.priority].color}`}>
{task.priority}
</span>
</td>
<td className="px-6 py-5">
<select value={task.status} onChange={e => updateStatus(task.id, e.target.value)} className={`px-3 py-1.5 rounded-xl text-[10px] font-black cursor-pointer border-none focus:ring-0 ${STATUSES[task.status]}`}>
{Object.keys(STATUSES).map(s => <option key={s} value={s}>{s}</option>)}
</select>
</td>
<td className="px-6 py-5 text-right">
<button onClick={() => removeTask(task.id)} className="text-slate-200 hover:text-rose-500 p-2"><Trash2 size={16} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Modal Crear Tarea */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-md animate-slide-up">
<div className="bg-white rounded-[3rem] w-full max-w-lg shadow-2xl overflow-hidden">
<div className="px-10 py-8 border-b border-slate-50 flex items-center justify-between">
<div>
<h2 className="text-2xl font-black text-slate-800 tracking-tight">Nueva Tarea</h2>
<p className="text-[10px] font-black text-indigo-600 uppercase tracking-widest mt-1">Sincronización en la Nube</p>
</div>
<button onClick={() => setIsModalOpen(false)} className="text-slate-300 hover:text-slate-600 transition-transform hover:rotate-90">
<XCircle size={32} />
</button>
</div>
<form onSubmit={addTask} className="p-10 space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Título</label>
<input required className="w-full px-6 py-4 bg-slate-50 border-none rounded-2xl outline-none font-bold text-sm focus:ring-2 focus:ring-indigo-500/10" placeholder="¿Qué hay que hacer?" value={newTask.title} onChange={e => setNewTask({...newTask, title: e.target.value})} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Responsable</label>
<input required className="w-full px-6 py-4 bg-slate-50 border-none rounded-2xl outline-none font-bold text-sm focus:ring-2 focus:ring-indigo-500/10" placeholder="Nombre" value={newTask.assignee} onChange={e => setNewTask({...newTask, assignee: e.target.value})} />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Prioridad</label>
<select className="w-full px-6 py-4 bg-slate-50 border-none rounded-2xl outline-none font-black text-xs uppercase cursor-pointer" value={newTask.priority} onChange={e => setNewTask({...newTask, priority: e.target.value})}>
{Object.keys(PRIORITIES).map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Descripción</label>
<textarea className="w-full px-6 py-4 bg-slate-50 border-none rounded-2xl outline-none font-medium text-sm focus:ring-2 focus:ring-indigo-500/10" rows="3" placeholder="Detalles de la tarea..." value={newTask.description} onChange={e => setNewTask({...newTask, description: e.target.value})}></textarea>
</div>
<button type="submit" className="w-full py-5 bg-indigo-600 text-white font-black rounded-2xl hover:bg-indigo-700 shadow-xl shadow-indigo-100 transition-all uppercase tracking-[0.2em] text-[10px]">
Publicar Tarea
</button>
</form>
</div>
</div>
)}
</div>
);
}
function TaskCard({ task, onUpdate, onDelete }) {
const Icon = PRIORITIES[task.priority].icon;
return (
<div className="bg-white p-6 rounded-[2.5rem] border border-slate-100 shadow-sm hover:shadow-xl hover:border-indigo-100 transition-all group animate-slide-up">
<div className="flex justify-between items-start mb-4">
<span className={`px-3 py-1 rounded-full text-[8px] font-black uppercase border tracking-widest ${PRIORITIES[task.priority].color} flex items-center gap-1.5`}>
<Icon size={12} /> {task.priority}
</span>
<button onClick={() => onDelete(task.id)} className="opacity-0 group-hover:opacity-100 p-2 text-slate-200 hover:text-rose-500 transition-all"><Trash2 size={14} /></button>
</div>
<h4 className="font-bold text-slate-800 text-sm mb-1 leading-tight group-hover:text-indigo-600 transition-colors">{task.title}</h4>
<p className="text-[10px] text-slate-400 line-clamp-2 mb-5 font-medium leading-relaxed">{task.description || 'Sin descripción'}</p>
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
<div className="flex items-center gap-2">
<div className="h-6 w-6 bg-slate-100 rounded-full flex items-center justify-center text-[8px] font-black text-slate-400 uppercase">{task.assignee.charAt(0)}</div>
<span className="text-[10px] font-bold text-slate-600">{task.assignee}</span>
</div>
<div className="flex gap-1.5">
{Object.keys(STATUSES).map(s => (
<button key={s} title={s} onClick={() => onUpdate(task.id, s)} className={`h-1.5 w-4 rounded-full transition-all ${task.status === s ? STATUSES[s].split(' ')[1].replace('text-', 'bg-') : 'bg-slate-100'}`}></button>
))}
</div>
</div>
</div>
);
}
function StatCard({ label, value, icon, color }) {
return (
<div className="bg-white p-6 rounded-[2.25rem] border border-slate-100 shadow-sm flex items-center gap-5 group hover:bg-slate-50 transition-colors">
<div className={`p-4 bg-slate-50 rounded-2xl ${color} group-hover:bg-white transition-colors`}>{React.cloneElement(icon, { size: 24 })}</div>
<div>
<p className="text-[9px] font-black uppercase tracking-widest text-slate-400 mb-0.5">{label}</p>
<p className="text-2xl font-black text-slate-800 leading-none">{value}</p>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>