Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Crypto Life Tracker

App web personal para registrar flujo entre wallets, Binance y bolívares con cálculo real de USD.

## Ejecutar localmente

```bash
npm install
npm run dev
```

Abrir `http://localhost:3000`.

## Deploy en Vercel
- Framework preset: **Next.js**.
- Root Directory: repositorio raíz.
- Build command: `next build`.

## Stack
- Next.js + React + TypeScript
- Tailwind CSS
- Recharts
- Persistencia en localStorage (`accounts`, `transactions`, `rates`)
11 changes: 11 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
@apply bg-slate-50 text-slate-900 antialiased;
}

* {
@apply border-border;
}
14 changes: 14 additions & 0 deletions app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
title: "Crypto Life Tracker",
description: "Control de gastos reales USD para flujo Solana/Binance/Bs",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>{children}</body>
</html>
);
}
139 changes: 139 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { SummaryCards } from "@/components/dashboard/summary-cards";
import { ChartsPanel } from "@/components/dashboard/charts-panel";
import { MovementForm } from "@/components/forms/movement-form";
import { AccountForm } from "@/components/forms/account-form";
import { RatesForm } from "@/components/forms/rates-form";
import { AccountsTable } from "@/components/tables/accounts-table";
import { MovementsTable } from "@/components/tables/movements-table";
import { Button } from "@/components/ui/button";
import { exportTransactionsCsv } from "@/lib/export";
import { monthlySummary } from "@/lib/calculations";
import { storage } from "@/lib/storage";
import { Account, MovementFilters, Rates, Transaction } from "@/types";

export default function HomePage() {
const [tab, setTab] = useState<"movimientos" | "dashboard" | "cuentas" | "config">("movimientos");
const [accounts, setAccounts] = useState<Account[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [rates, setRates] = useState<Rates>({ bcv: 42, p2p: 62 });
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [selectedMonth, setSelectedMonth] = useState(new Date().toISOString().slice(0, 7));
const [filters, setFilters] = useState<MovementFilters>({ type: "todos", accountId: "todas", category: "todas" });

useEffect(() => {
setAccounts(storage.getAccounts());
setTransactions(storage.getTransactions());
setRates(storage.getRates());
}, []);

useEffect(() => storage.saveAccounts(accounts), [accounts]);
useEffect(() => storage.saveTransactions(transactions), [transactions]);
useEffect(() => storage.saveRates(rates), [rates]);

const filteredTransactions = useMemo(() => transactions.filter((tx) => {
if (filters.fromDate && tx.date < filters.fromDate) return false;
if (filters.toDate && tx.date > filters.toDate) return false;
if (filters.type && filters.type !== "todos" && tx.type !== filters.type) return false;

const related = tx.accountId ?? tx.sourceAccountId ?? tx.destinationAccountId;
if (filters.accountId && filters.accountId !== "todas" && related !== filters.accountId && tx.sourceAccountId !== filters.accountId && tx.destinationAccountId !== filters.accountId) return false;

if (filters.category && filters.category !== "todas" && tx.category !== filters.category) return false;
return true;
}).sort((a, b) => b.date.localeCompare(a.date)), [transactions, filters]);

const summary = useMemo(() => monthlySummary(transactions, selectedMonth), [transactions, selectedMonth]);

return (
<main className="mx-auto max-w-7xl p-4 md:p-8">
<header className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Crypto Life Tracker</h1>
<p className="text-sm text-slate-600">Controla tu gasto real en USD según la tasa real que usaste.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => exportTransactionsCsv(filteredTransactions, accounts)}>Exportar CSV</Button>
<Button variant="danger" onClick={() => {
if (!confirm("¿Seguro que quieres limpiar datos de prueba?")) return;
storage.resetDemo();
setAccounts(storage.getAccounts());
setTransactions([]);
setRates(storage.getRates());
}}>Limpiar datos</Button>
</div>
</header>

<div className="mb-4 flex flex-wrap gap-2">
{(["movimientos", "dashboard", "cuentas", "config"] as const).map((name) => (
<Button key={name} variant={tab === name ? "default" : "outline"} onClick={() => setTab(name)} className="capitalize">{name}</Button>
))}
<input type="month" value={selectedMonth} onChange={(e) => setSelectedMonth(e.target.value)} className="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm" />
</div>

<section className="space-y-4">
<SummaryCards gasto={summary.totalGasto} ingreso={summary.totalIngreso} promedio={summary.promedioDiario} ahorro={summary.ahorroVsBCV} />

{tab === "movimientos" && (
<>
<MovementForm
accounts={accounts}
rates={rates}
editing={editingTx}
onCancelEdit={() => setEditingTx(null)}
onSave={(tx) => {
setTransactions((prev) => {
const found = prev.some((p) => p.id === tx.id);
return found ? prev.map((p) => (p.id === tx.id ? tx : p)) : [tx, ...prev];
});
setEditingTx(null);
}}
/>
<MovementsTable
data={filteredTransactions}
accounts={accounts}
filters={filters}
onFilters={setFilters}
onEdit={setEditingTx}
onDelete={(id) => {
if (!confirm("¿Eliminar movimiento?")) return;
setTransactions((prev) => prev.filter((tx) => tx.id !== id));
}}
/>
</>
)}

{tab === "dashboard" && <ChartsPanel transactions={filteredTransactions} accounts={accounts} />}

{tab === "cuentas" && (
<>
<AccountForm
editing={editingAccount}
onCancel={() => setEditingAccount(null)}
onSave={(acc) => {
setAccounts((prev) => {
const found = prev.some((p) => p.id === acc.id);
return found ? prev.map((p) => (p.id === acc.id ? acc : p)) : [...prev, acc];
});
setEditingAccount(null);
}}
/>
<AccountsTable
accounts={accounts}
onEdit={setEditingAccount}
onDelete={(id) => {
if (!confirm("¿Eliminar cuenta?")) return;
setAccounts((prev) => prev.filter((acc) => acc.id !== id));
}}
/>
</>
)}

{tab === "config" && <RatesForm rates={rates} onSave={setRates} />}
</section>
</main>
);
}
86 changes: 86 additions & 0 deletions components/dashboard/charts-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { Card } from "@/components/ui/card";
import { Transaction, Account } from "@/types";
import { Bar, BarChart, CartesianGrid, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { monthLabel } from "@/lib/calculations";

export function ChartsPanel({ transactions, accounts }: { transactions: Transaction[]; accounts: Account[] }) {
const expensesByCategory = Object.entries(
transactions.filter((tx) => tx.type === "gasto").reduce<Record<string, number>>((acc, tx) => {
const key = tx.category || "Otros";
acc[key] = (acc[key] || 0) + tx.realUsd;
return acc;
}, {}),
).map(([name, value]) => ({ name, value: Number(value.toFixed(2)) }));

const expensesByAccount = Object.entries(
transactions.filter((tx) => tx.type === "gasto").reduce<Record<string, number>>((acc, tx) => {
const name = accounts.find((a) => a.id === tx.accountId)?.name || "Sin cuenta";
acc[name] = (acc[name] || 0) + tx.realUsd;
return acc;
}, {}),
).map(([name, value]) => ({ name, gasto: Number(value.toFixed(2)) }));

const monthlyBalance = Object.entries(
transactions.reduce<Record<string, { ingresos: number; gastos: number }>>((acc, tx) => {
const key = tx.date.slice(0, 7);
if (!acc[key]) acc[key] = { ingresos: 0, gastos: 0 };
if (tx.type === "ingreso") acc[key].ingresos += tx.realUsd;
if (tx.type === "gasto") acc[key].gastos += tx.realUsd;
return acc;
}, {}),
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, values]) => ({ month: monthLabel(`${month}-01`), ...values }));

return (
<div className="grid gap-4 lg:grid-cols-2">
<Card className="h-[320px]">
<p className="mb-2 font-medium">Gastos por categoría</p>
{expensesByCategory.length === 0 ? <EmptyChart /> : (
<ResponsiveContainer width="100%" height="90%">
<PieChart>
<Tooltip />
<Pie data={expensesByCategory} dataKey="value" nameKey="name" outerRadius={100} fill="#2563eb" label />
</PieChart>
</ResponsiveContainer>
)}
</Card>
<Card className="h-[320px]">
<p className="mb-2 font-medium">Gastos por cuenta</p>
{expensesByAccount.length === 0 ? <EmptyChart /> : (
<ResponsiveContainer width="100%" height="90%">
<BarChart data={expensesByAccount}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis />
<Tooltip />
<Bar dataKey="gasto" fill="#16a34a" />
</BarChart>
</ResponsiveContainer>
)}
</Card>
<Card className="h-[340px] lg:col-span-2">
<p className="mb-2 font-medium">Ingresos vs gastos por mes</p>
{monthlyBalance.length === 0 ? <EmptyChart /> : (
<ResponsiveContainer width="100%" height="90%">
<BarChart data={monthlyBalance}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="ingresos" fill="#2563eb" />
<Bar dataKey="gastos" fill="#ef4444" />
</BarChart>
</ResponsiveContainer>
)}
</Card>
</div>
);
}

function EmptyChart() {
return <div className="flex h-[85%] items-center justify-center text-sm text-slate-500">Aún no hay datos para mostrar.</div>;
}
33 changes: 33 additions & 0 deletions components/dashboard/summary-cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Card } from "@/components/ui/card";

const money = (n: number) => `$${n.toFixed(2)}`;

export function SummaryCards({
gasto,
ingreso,
promedio,
ahorro,
}: {
gasto: number;
ingreso: number;
promedio: number;
ahorro: number;
}) {
const data = [
{ label: "Gasto del mes", value: money(gasto) },
{ label: "Ingresos del mes", value: money(ingreso) },
{ label: "Promedio diario", value: money(promedio) },
{ label: "Ahorro vs BCV", value: money(ahorro) },
];

return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{data.map((item) => (
<Card key={item.label}>
<p className="text-xs text-slate-500">{item.label}</p>
<p className="mt-2 text-2xl font-semibold">{item.value}</p>
</Card>
))}
</div>
);
}
44 changes: 44 additions & 0 deletions components/forms/account-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { useEffect, useState } from "react";
import { Account, AccountType, Currency } from "@/types";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Button } from "@/components/ui/button";

export function AccountForm({ editing, onSave, onCancel }: { editing?: Account | null; onSave: (acc: Account) => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [type, setType] = useState<AccountType>("Wallet");
const [mainCurrency, setMainCurrency] = useState<Currency>("USDT");

useEffect(() => {
if (!editing) return;
setName(editing.name);
setType(editing.type);
setMainCurrency(editing.mainCurrency);
}, [editing]);

return (
<Card>
<h3 className="mb-3 font-semibold">{editing ? "Editar cuenta" : "Crear cuenta"}</h3>
<div className="grid gap-3 md:grid-cols-3">
<Input placeholder="Nombre de cuenta" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={type} onChange={(e) => setType(e.target.value as AccountType)}>
<option>Wallet</option><option>Exchange</option><option>Banco</option><option>Efectivo</option>
</Select>
<Select value={mainCurrency} onChange={(e) => setMainCurrency(e.target.value as Currency)}>
<option>Bs</option><option>USD</option><option>USDT</option>
</Select>
</div>
<div className="mt-3 flex gap-2">
<Button onClick={() => {
if (!name.trim()) return;
onSave({ id: editing?.id ?? crypto.randomUUID(), name: name.trim(), type, mainCurrency, createdAt: editing?.createdAt ?? new Date().toISOString() });
if (!editing) setName("");
}}>{editing ? "Actualizar" : "Crear"}</Button>
{editing && <Button variant="outline" onClick={onCancel}>Cancelar</Button>}
</div>
</Card>
);
}
Loading