From 0132c40cb8c1e34f04e42b9c33aef00299e577da Mon Sep 17 00:00:00 2001 From: Maikol Castellano <56364360+soymaikoldev@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:44:28 -0400 Subject: [PATCH] fix: replace invalid favicon with valid app icon --- README.md | 23 ++++ app/globals.css | 11 ++ app/icon.svg | 14 +++ app/layout.tsx | 15 +++ app/page.tsx | 139 +++++++++++++++++++++++ components/dashboard/charts-panel.tsx | 86 ++++++++++++++ components/dashboard/summary-cards.tsx | 33 ++++++ components/forms/account-form.tsx | 44 ++++++++ components/forms/movement-form.tsx | 150 +++++++++++++++++++++++++ components/forms/rates-form.tsx | 23 ++++ components/tables/accounts-table.tsx | 30 +++++ components/tables/movements-table.tsx | 77 +++++++++++++ components/ui/button.tsx | 21 ++++ components/ui/card.tsx | 6 + components/ui/input.tsx | 6 + components/ui/select.tsx | 10 ++ components/ui/textarea.tsx | 6 + eslint.config.mjs | 3 + lib/calculations.ts | 52 +++++++++ lib/constants.ts | 20 ++++ lib/export.ts | 23 ++++ lib/storage.ts | 35 ++++++ lib/utils.ts | 6 + next-env.d.ts | 4 + next.config.mjs | 6 + package.json | 33 ++++++ postcss.config.mjs | 6 + tailwind.config.ts | 29 +++++ tsconfig.json | 23 ++++ types/index.ts | 42 +++++++ vercel.json | 3 + 31 files changed, 979 insertions(+) create mode 100644 README.md create mode 100644 app/globals.css create mode 100644 app/icon.svg create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/dashboard/charts-panel.tsx create mode 100644 components/dashboard/summary-cards.tsx create mode 100644 components/forms/account-form.tsx create mode 100644 components/forms/movement-form.tsx create mode 100644 components/forms/rates-form.tsx create mode 100644 components/tables/accounts-table.tsx create mode 100644 components/tables/movements-table.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 eslint.config.mjs create mode 100644 lib/calculations.ts create mode 100644 lib/constants.ts create mode 100644 lib/export.ts create mode 100644 lib/storage.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/index.ts create mode 100644 vercel.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..90f275c --- /dev/null +++ b/README.md @@ -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`) diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..d57fbe8 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,11 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-slate-50 text-slate-900 antialiased; +} + +* { + @apply border-border; +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..c447052 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9f24b21 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..2e2d1b6 --- /dev/null +++ b/app/page.tsx @@ -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([]); + const [transactions, setTransactions] = useState([]); + const [rates, setRates] = useState({ bcv: 42, p2p: 62 }); + const [editingTx, setEditingTx] = useState(null); + const [editingAccount, setEditingAccount] = useState(null); + const [selectedMonth, setSelectedMonth] = useState(new Date().toISOString().slice(0, 7)); + const [filters, setFilters] = useState({ 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 ( +
+
+
+

Crypto Life Tracker

+

Controla tu gasto real en USD según la tasa real que usaste.

+
+
+ + +
+
+ +
+ {(["movimientos", "dashboard", "cuentas", "config"] as const).map((name) => ( + + ))} + setSelectedMonth(e.target.value)} className="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm" /> +
+ +
+ + + {tab === "movimientos" && ( + <> + 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); + }} + /> + { + if (!confirm("¿Eliminar movimiento?")) return; + setTransactions((prev) => prev.filter((tx) => tx.id !== id)); + }} + /> + + )} + + {tab === "dashboard" && } + + {tab === "cuentas" && ( + <> + 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); + }} + /> + { + if (!confirm("¿Eliminar cuenta?")) return; + setAccounts((prev) => prev.filter((acc) => acc.id !== id)); + }} + /> + + )} + + {tab === "config" && } +
+
+ ); +} diff --git a/components/dashboard/charts-panel.tsx b/components/dashboard/charts-panel.tsx new file mode 100644 index 0000000..75325d2 --- /dev/null +++ b/components/dashboard/charts-panel.tsx @@ -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>((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>((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>((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 ( +
+ +

Gastos por categoría

+ {expensesByCategory.length === 0 ? : ( + + + + + + + )} +
+ +

Gastos por cuenta

+ {expensesByAccount.length === 0 ? : ( + + + + + + + + + + )} +
+ +

Ingresos vs gastos por mes

+ {monthlyBalance.length === 0 ? : ( + + + + + + + + + + + + )} +
+
+ ); +} + +function EmptyChart() { + return
Aún no hay datos para mostrar.
; +} diff --git a/components/dashboard/summary-cards.tsx b/components/dashboard/summary-cards.tsx new file mode 100644 index 0000000..fc9b21d --- /dev/null +++ b/components/dashboard/summary-cards.tsx @@ -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 ( +
+ {data.map((item) => ( + +

{item.label}

+

{item.value}

+
+ ))} +
+ ); +} diff --git a/components/forms/account-form.tsx b/components/forms/account-form.tsx new file mode 100644 index 0000000..684d04d --- /dev/null +++ b/components/forms/account-form.tsx @@ -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("Wallet"); + const [mainCurrency, setMainCurrency] = useState("USDT"); + + useEffect(() => { + if (!editing) return; + setName(editing.name); + setType(editing.type); + setMainCurrency(editing.mainCurrency); + }, [editing]); + + return ( + +

{editing ? "Editar cuenta" : "Crear cuenta"}

+
+ setName(e.target.value)} /> + + +
+
+ + {editing && } +
+
+ ); +} diff --git a/components/forms/movement-form.tsx b/components/forms/movement-form.tsx new file mode 100644 index 0000000..0865b66 --- /dev/null +++ b/components/forms/movement-form.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Account, Currency, Rates, Transaction, TransactionType } from "@/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Card } from "@/components/ui/card"; +import { CATEGORIES } from "@/lib/constants"; +import { enrichWithUsd } from "@/lib/calculations"; + +interface Props { + accounts: Account[]; + rates: Rates; + editing?: Transaction | null; + onSave: (tx: Transaction) => void; + onCancelEdit: () => void; +} + +export function MovementForm({ accounts, rates, editing, onSave, onCancelEdit }: Props) { + const [type, setType] = useState(editing?.type ?? "gasto"); + const [date, setDate] = useState(editing?.date ?? new Date().toISOString().slice(0, 10)); + const [accountId, setAccountId] = useState(editing?.accountId ?? accounts[0]?.id ?? ""); + const [sourceAccountId, setSourceAccountId] = useState(editing?.sourceAccountId ?? accounts[0]?.id ?? ""); + const [destinationAccountId, setDestinationAccountId] = useState(editing?.destinationAccountId ?? accounts[1]?.id ?? ""); + const [category, setCategory] = useState(editing?.category ?? CATEGORIES[0]); + const [amount, setAmount] = useState(editing ? String(editing.amount) : ""); + const [currency, setCurrency] = useState(editing?.currency ?? "Bs"); + const [usedRate, setUsedRate] = useState(editing?.usedRate ? String(editing.usedRate) : String(rates.p2p)); + const [note, setNote] = useState(editing?.note ?? ""); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (editing) { + setType(editing.type); + setDate(editing.date); + setAccountId(editing.accountId ?? accounts[0]?.id ?? ""); + setSourceAccountId(editing.sourceAccountId ?? accounts[0]?.id ?? ""); + setDestinationAccountId(editing.destinationAccountId ?? accounts[1]?.id ?? ""); + setCategory(editing.category ?? CATEGORIES[0]); + setAmount(String(editing.amount)); + setCurrency(editing.currency); + setUsedRate(String(editing.usedRate ?? rates.p2p)); + setNote(editing.note ?? ""); + setMessage(""); + return; + } + + setType("gasto"); + setDate(new Date().toISOString().slice(0, 10)); + setAccountId(accounts[0]?.id ?? ""); + setSourceAccountId(accounts[0]?.id ?? ""); + setDestinationAccountId(accounts[1]?.id ?? accounts[0]?.id ?? ""); + setCategory(CATEGORIES[0]); + setAmount(""); + setCurrency("Bs"); + setUsedRate(String(rates.p2p)); + setNote(""); + setMessage(""); + }, [editing, accounts, rates.p2p]); + + const preview = useMemo(() => { + const parsed = Number(amount || 0); + return enrichWithUsd(parsed, currency, rates, Number(usedRate)); + }, [amount, currency, rates, usedRate]); + + const submit = () => { + const parsedAmount = Number(amount); + if (!date || Number.isNaN(parsedAmount) || parsedAmount <= 0) { + setMessage("Completa fecha y monto válido."); + return; + } + if ((type === "gasto" || type === "ingreso") && !accountId) { + setMessage("Selecciona una cuenta."); + return; + } + if (type === "transferencia" && (!sourceAccountId || !destinationAccountId || sourceAccountId === destinationAccountId)) { + setMessage("Elige cuentas origen/destino distintas."); + return; + } + + const usd = enrichWithUsd(parsedAmount, currency, rates, Number(usedRate)); + const tx: Transaction = { + id: editing?.id ?? crypto.randomUUID(), + createdAt: editing?.createdAt ?? new Date().toISOString(), + date, + type, + amount: parsedAmount, + currency, + note, + category: type === "transferencia" ? undefined : category, + accountId: type === "transferencia" ? undefined : accountId, + sourceAccountId: type === "transferencia" ? sourceAccountId : undefined, + destinationAccountId: type === "transferencia" ? destinationAccountId : undefined, + usedRate: currency === "Bs" ? Number(usedRate) : undefined, + realUsd: usd.realUsd, + bcvUsd: usd.bcvUsd, + diffUsd: usd.diffUsd, + }; + + onSave(tx); + setMessage(editing ? "Movimiento actualizado." : "Movimiento guardado."); + if (!editing) { + setAmount(""); + setNote(""); + } + }; + + return ( + +
+

{editing ? "Editar movimiento" : "Nuevo movimiento"}

+ {editing && } +
+
+ + setDate(e.target.value)} /> + setAmount(e.target.value)} /> + + {currency === "Bs" && ( + setUsedRate(e.target.value)} /> + )} + {type !== "transferencia" && ( + <> + + + + )} + {type === "transferencia" && ( + <> + + + + )} +