-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Open
Description
`
<title>Controle de Gastos - Mensal e Semanal</title> <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> <script src="https://cdn.tailwindcss.com"></script><!-- Firebase Libraries -->
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, setDoc, onSnapshot, collection, query, deleteDoc } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
const firebaseConfig = JSON.parse(__firebase_config);
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'controle-gastos-v4';
window.FB = { auth, db, appId, doc, setDoc, onSnapshot, collection, query, deleteDoc, signInAnonymously, signInWithCustomToken };
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; -webkit-tap-highlight-color: transparent; }
.animate-pop { animation: pop 0.25s ease-out; }
@keyframes pop { 0% { transform: scale(0.98); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.no-scrollbar::-webkit-scrollbar { display: none; }
.glass { background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(10px); }
</style>
<script type="text/babel">
const { useState, useEffect, useMemo } = React;
const CATEGORIES = [
{ id: 'mercado', nome: 'Mercado', icon: '🛒', color: 'bg-blue-500' },
{ id: 'feira', nome: 'Feira', icon: '🍎', color: 'bg-green-500' },
{ id: 'farmacia', nome: 'Farmácia', icon: '💊', color: 'bg-red-500' },
{ id: 'restaurante', nome: 'Restaurante', icon: '☕', color: 'bg-orange-500' },
{ id: 'outros', nome: 'Outros', icon: '✨', color: 'bg-slate-500' }
];
const PAY_METHODS = { 'Crédito': '💳', 'Débito': '🏧', 'PIX': '📱', 'Dinheiro': '💵' };
const MONTHS = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const fmtCur = (v) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v || 0);
function App() {
const [user, setUser] = useState(null);
const [activeTab, setActiveTab] = useState('dashboard');
const [transactions, setTransactions] = useState([]);
const [config, setConfig] = useState({ tetoGlobal: 1500, subtetos: {} });
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null);
// Estado do Filtro Temporal
const today = new Date();
const [viewDate, setViewDate] = useState({ month: today.getMonth(), year: today.getFullYear() });
// 1. Firebase Auth
useEffect(() => {
const initAuth = async () => {
const { auth, signInAnonymously, signInWithCustomToken } = window.FB;
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
};
initAuth();
return window.FB.auth.onAuthStateChanged(u => setUser(u));
}, []);
// 2. Firestore Listeners
useEffect(() => {
if (!user) return;
const { db, appId, onSnapshot, doc, collection } = window.FB;
const transCol = collection(db, 'artifacts', appId, 'users', user.uid, 'transactions');
const unsubTrans = onSnapshot(transCol, (snapshot) => {
const items = snapshot.docs.map(d => ({ ...d.data(), id: d.id }));
setTransactions(items);
setLoading(false);
});
const configDoc = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'main');
const unsubConfig = onSnapshot(configDoc, (docSnap) => {
if (docSnap.exists()) setConfig(docSnap.data());
});
return () => { unsubTrans(); unsubConfig(); };
}, [user]);
// 3. Cálculos Derivados (Filtros de Período)
const filteredData = useMemo(() => {
return transactions.filter(t => {
const d = new Date(t.data + 'T00:00:00');
return d.getMonth() === viewDate.month && d.getFullYear() === viewDate.year;
}).sort((a, b) => new Date(b.data) - new Date(a.data));
}, [transactions, viewDate]);
const weeklyTotal = useMemo(() => {
const now = new Date();
const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay()));
startOfWeek.setHours(0,0,0,0);
return filteredData.filter(t => {
const d = new Date(t.data + 'T00:00:00');
return d >= startOfWeek;
}).reduce((acc, t) => acc + t.valor, 0);
}, [filteredData]);
const monthlyTotal = filteredData.reduce((acc, t) => acc + t.valor, 0);
const percentual = (monthlyTotal / config.tetoGlobal) * 100;
// 4. Ações
const handleSave = async (item) => {
const { db, appId, doc, setDoc } = window.FB;
const id = editingItem ? editingItem.id : Date.now().toString();
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'transactions', id);
await setDoc(docRef, item);
setIsModalOpen(false);
setEditingItem(null);
};
const handleDelete = async (id) => {
if (!confirm("Excluir este gasto?")) return;
const { db, appId, doc, deleteDoc } = window.FB;
await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'transactions', id));
setIsModalOpen(false);
setEditingItem(null);
};
const changeMonth = (dir) => {
setViewDate(prev => {
let newMonth = prev.month + dir;
let newYear = prev.year;
if (newMonth > 11) { newMonth = 0; newYear++; }
if (newMonth < 0) { newMonth = 11; newYear--; }
return { month: newMonth, year: newYear };
});
};
if (loading) return (
<div className="flex flex-col items-center justify-center min-h-screen bg-white">
<div className="w-10 h-10 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
return (
<div className="max-w-md mx-auto min-h-screen flex flex-col bg-slate-50 relative overflow-x-hidden">
{/* Top Bar - Seletor de Mês */}
<div className="bg-white px-5 py-4 border-b flex justify-between items-center sticky top-0 z-30 shadow-sm">
<button onClick={() => changeMonth(-1)} className="p-2 hover:bg-slate-100 rounded-full">◀</button>
<div className="text-center">
<h2 className="font-black text-slate-800 uppercase text-sm tracking-widest">{MONTHS[viewDate.month]}</h2>
<p className="text-[10px] font-bold text-slate-400">{viewDate.year}</p>
</div>
<button onClick={() => changeMonth(1)} className="p-2 hover:bg-slate-100 rounded-full">▶</button>
</div>
<main className="p-4 flex-1 mb-24 overflow-y-auto no-scrollbar">
{activeTab === 'dashboard' && (
<div className="animate-pop space-y-6">
{/* Resumo Semanal Mini */}
<div className="flex gap-3">
<div className="flex-1 bg-white p-4 rounded-3xl border border-slate-100 shadow-sm">
<p className="text-[9px] font-black text-slate-400 uppercase tracking-tighter mb-1">Gasto na Semana</p>
<p className="text-lg font-black text-slate-800">{fmtCur(weeklyTotal)}</p>
</div>
<div className="flex-1 bg-white p-4 rounded-3xl border border-slate-100 shadow-sm">
<p className="text-[9px] font-black text-slate-400 uppercase tracking-tighter mb-1">Média Diária</p>
<p className="text-lg font-black text-indigo-600">{fmtCur(monthlyTotal / (new Date(viewDate.year, viewDate.month + 1, 0).getDate()))}</p>
</div>
</div>
{/* Card Mensal Principal */}
<div className="bg-gradient-to-br from-indigo-600 to-indigo-900 rounded-[2.5rem] p-7 text-white shadow-xl shadow-indigo-100 relative overflow-hidden">
<div className="absolute top-[-10%] right-[-10%] w-40 h-40 bg-white/10 rounded-full blur-3xl"></div>
<p className="text-indigo-100 text-[10px] font-black uppercase tracking-widest mb-1">Total {MONTHS[viewDate.month]}</p>
<h2 className="text-4xl font-black mb-6 tracking-tighter">{fmtCur(monthlyTotal)}</h2>
<div className="w-full bg-black/20 h-4 rounded-full overflow-hidden mb-3 p-1">
<div className={`h-full rounded-full transition-all duration-1000 ${percentual > 100 ? 'bg-red-400' : 'bg-emerald-400'}`} style={{ width: `${Math.min(percentual, 100)}%` }}></div>
</div>
<div className="flex justify-between text-[11px] font-black opacity-80 uppercase">
<span>Meta: {fmtCur(config.tetoGlobal)}</span>
<span>{percentual.toFixed(1)}%</span>
</div>
</div>
{/* Categorias no Mês */}
<div className="grid grid-cols-2 gap-4">
{CATEGORIES.map(cat => {
const gastoCat = filteredData.filter(t => t.categoria === cat.id).reduce((a, b) => a + b.valor, 0);
const subteto = config.subtetos?.[cat.id] || 0;
const catPct = subteto > 0 ? (gastoCat / subteto) * 100 : 0;
return (
<div key={cat.id} className="bg-white p-4 rounded-3xl border border-slate-100 shadow-sm flex flex-col justify-between h-32 relative overflow-hidden">
<div className="flex justify-between items-start z-10">
<span className="text-2xl">{cat.icon}</span>
{subteto > 0 && <span className={`text-[9px] font-black px-2 py-0.5 rounded-lg ${catPct >= 100 ? 'bg-red-100 text-red-600' : 'bg-slate-100 text-slate-500'}`}>{catPct.toFixed(0)}%</span>}
</div>
<div className="z-10">
<p className="text-[10px] font-bold text-slate-400 uppercase leading-none mb-1">{cat.nome}</p>
<p className="text-sm font-black text-slate-800">{fmtCur(gastoCat)}</p>
</div>
{subteto > 0 && <div className="absolute bottom-0 left-0 h-1.5 bg-slate-50 w-full"><div className={`h-full ${catPct >= 100 ? 'bg-red-500' : cat.color} transition-all duration-700`} style={{ width: `${Math.min(catPct, 100)}%` }}></div></div>}
</div>
);
})}
</div>
{/* Últimos Lançamentos do Mês */}
<div>
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4 px-1">Atividade em {MONTHS[viewDate.month]}</h3>
<div className="space-y-3">
{filteredData.slice(0, 8).map(t => (
<div key={t.id} onClick={() => { setEditingItem(t); setIsModalOpen(true); }} className="bg-white p-4 rounded-3xl flex justify-between items-center border border-slate-100 shadow-sm active:scale-95 transition-all">
<div className="flex items-center gap-4">
<div className="text-xl bg-slate-50 w-12 h-12 rounded-2xl flex items-center justify-center">{CATEGORIES.find(c => c.id === t.categoria)?.icon}</div>
<div>
<p className="text-sm font-bold text-slate-800 line-clamp-1">{t.descricao}</p>
<p className="text-[9px] text-slate-300 font-black uppercase">{t.data.split('-').reverse().slice(0,2).join('/')} • {t.formaPagamento}</p>
</div>
</div>
<p className="font-black text-slate-800">{fmtCur(t.valor)}</p>
</div>
))}
</div>
</div>
</div>
)}
{activeTab === 'history' && (
<div className="animate-pop space-y-4">
<h2 className="font-black text-xl text-slate-800 mb-4 px-1">Todos os Gastos de {MONTHS[viewDate.month]}</h2>
{filteredData.length === 0 ? (
<div className="text-center py-20 opacity-30">
<span className="text-5xl block mb-2">📄</span>
<p className="font-bold uppercase text-xs">Sem registros este mês</p>
</div>
) : filteredData.map(t => (
<div key={t.id} onClick={() => { setEditingItem(t); setIsModalOpen(true); }} className="bg-white p-5 rounded-[2rem] flex justify-between items-center border border-slate-100 shadow-sm">
<div>
<p className="text-[9px] font-black text-indigo-500 mb-1 uppercase tracking-tighter">{t.data.split('-').reverse().join('/')}</p>
<p className="font-bold text-slate-700 leading-tight">{t.descricao}</p>
<p className="text-[9px] font-black uppercase text-slate-300 mt-1">{CATEGORIES.find(c => c.id === t.categoria)?.nome}</p>
</div>
<div className="text-right">
<p className="font-black text-slate-800 text-lg">{fmtCur(t.valor)}</p>
<p className="text-[9px] text-slate-400 font-bold uppercase tracking-widest">{t.formaPagamento}</p>
</div>
</div>
))}
</div>
)}
{activeTab === 'config' && (
<div className="animate-pop space-y-6">
<h2 className="font-black text-xl text-slate-800 px-1">Definições de Orçamento</h2>
<div className="bg-white p-7 rounded-[2.5rem] border border-slate-100 shadow-sm space-y-8">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase block mb-3 tracking-widest">Teto Global Mensal</label>
<input type="number" value={config.tetoGlobal} onChange={e => {
const val = Number(e.target.value);
setConfig({...config, tetoGlobal: val});
window.FB.setDoc(window.FB.doc(window.FB.db, 'artifacts', window.FB.appId, 'users', user.uid, 'settings', 'main'), {...config, tetoGlobal: val});
}} className="w-full bg-slate-50 p-5 rounded-3xl font-black text-4xl text-indigo-600 outline-none border-2 border-transparent focus:border-indigo-100 transition-all" />
</div>
<div className="space-y-4">
<label className="text-[10px] font-black text-slate-400 uppercase block mb-2 tracking-widest">Metas por Categoria</label>
{CATEGORIES.map(cat => (
<div key={cat.id} className="flex items-center gap-4 bg-slate-50 p-4 rounded-3xl border border-slate-100">
<span className="text-2xl">{cat.icon}</span>
<span className="text-xs font-black text-slate-600 flex-1">{cat.nome}</span>
<input type="number" value={config.subtetos?.[cat.id] || ''} placeholder="0" onChange={e => {
const newSub = {...(config.subtetos||{}), [cat.id]: Number(e.target.value)};
const newConf = {...config, subtetos: newSub};
setConfig(newConf);
window.FB.setDoc(window.FB.doc(window.FB.db, 'artifacts', window.FB.appId, 'users', user.uid, 'settings', 'main'), newConf);
}} className="w-24 bg-white p-2 rounded-xl font-black text-right outline-none border border-slate-100 text-slate-800" />
</div>
))}
</div>
</div>
<div className="p-4 bg-indigo-50 rounded-3xl border border-indigo-100">
<p className="text-[10px] font-bold text-indigo-400 uppercase mb-1">ID da Nuvem</p>
<p className="text-[9px] font-mono text-indigo-300 break-all">{user.uid}</p>
</div>
</div>
)}
</main>
{/* Botão Flutuante */}
<button onClick={() => { setEditingItem(null); setIsModalOpen(true); }} className="fixed bottom-28 right-6 w-16 h-16 bg-indigo-600 text-white rounded-3xl shadow-2xl shadow-indigo-200 flex items-center justify-center active:scale-90 transition-all z-40">
<span className="text-4xl font-light">+</span>
</button>
{/* Bottom Nav */}
<nav className="fixed bottom-0 left-0 right-0 glass border-t p-6 flex justify-around items-center z-50 pb-8">
<NavButton icon="🏠" label="Resumo" active={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')} />
<NavButton icon="📋" label="Histórico" active={activeTab === 'history'} onClick={() => setActiveTab('history')} />
<div className="w-12"></div>
<NavButton icon="🎯" label="Metas" active={activeTab === 'config'} onClick={() => setActiveTab('config')} />
<NavButton icon="👤" label="Perfil" onClick={() => alert("Sincronizado via: " + user.uid.substring(0,8))} />
</nav>
{/* Modal Form */}
{isModalOpen && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 z-[100]">
<div className="bg-white w-full max-w-md p-7 rounded-t-[3rem] sm:rounded-[3rem] animate-pop shadow-2xl">
<div className="flex justify-between items-center mb-8">
<h2 className="font-black text-2xl text-slate-800">{editingItem ? '✏️ Editar' : '✨ Novo Gasto'}</h2>
<button onClick={() => setIsModalOpen(false)} className="text-slate-300 text-4xl">×</button>
</div>
<form onSubmit={e => {
e.preventDefault();
const fd = new FormData(e.target);
handleSave({
data: fd.get('data'),
valor: parseFloat(fd.get('valor')),
descricao: fd.get('descricao'),
categoria: fd.get('categoria'),
formaPagamento: fd.get('forma')
});
}} className="space-y-6">
<div className="bg-slate-50 p-7 rounded-[2.5rem] border border-slate-100 text-center">
<label className="text-[10px] font-black text-slate-400 block mb-2 uppercase tracking-widest">Valor do Lançamento</label>
<div className="flex justify-center items-center">
<span className="text-2xl font-black text-slate-200 mr-2">R$</span>
<input name="valor" type="number" step="0.01" required autoFocus defaultValue={editingItem?.valor || ''} className="w-40 bg-transparent text-5xl font-black outline-none text-indigo-600 text-center" placeholder="0,00" />
</div>
</div>
<div className="space-y-4">
<input name="descricao" required defaultValue={editingItem?.descricao || ''} placeholder="Onde você gastou?" className="w-full bg-slate-50 p-5 rounded-2xl font-bold outline-none border border-slate-100 placeholder:text-slate-300" />
<div className="grid grid-cols-2 gap-4">
<select name="categoria" defaultValue={editingItem?.categoria || 'mercado'} className="bg-slate-50 p-4 rounded-2xl font-bold outline-none border border-slate-100 text-slate-600">
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.icon} {c.nome}</option>)}
</select>
<select name="forma" defaultValue={editingItem?.formaPagamento || 'Débito'} className="bg-slate-50 p-4 rounded-2xl font-bold outline-none border border-slate-100 text-slate-600">
{Object.keys(PAY_METHODS).map(f => <option key={f} value={f}>{PAY_METHODS[f]} {f}</option>)}
</select>
</div>
<input name="data" type="date" defaultValue={editingItem?.data || new Date().toISOString().split('T')[0]} className="w-full bg-slate-50 p-5 rounded-2xl font-bold outline-none border border-slate-100 text-slate-500" />
</div>
<div className="flex gap-3">
{editingItem && (
<button type="button" onClick={() => handleDelete(editingItem.id)} className="w-16 h-16 bg-red-50 text-red-500 rounded-3xl flex items-center justify-center text-xl">🗑️</button>
)}
<button type="submit" className="flex-1 bg-indigo-600 text-white h-16 rounded-3xl font-black uppercase tracking-widest text-xs shadow-xl active:scale-95 transition-all">Sincronizar Agora</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
function NavButton({ icon, label, active, onClick }) {
return (
<button onClick={onClick} className={`flex flex-col items-center gap-1 transition-all ${active ? 'text-indigo-600 scale-110' : 'text-slate-300'}`}>
<span className="text-xl">{icon}</span>
<span className="text-[9px] font-black uppercase tracking-tighter">{label}</span>
</button>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels