From 9104cc9d721762db89a5a6adc4c3948d5fcfd45e Mon Sep 17 00:00:00 2001 From: MarioVicente Date: Tue, 7 Oct 2025 14:01:57 -0600 Subject: [PATCH] Adding feature to save cards on cosmos --- api/cards-delete/function.json | 6 +++ api/cards-delete/index.js | 32 ++++++++++++++ api/cards-get/function.json | 6 +++ api/cards-get/index.js | 34 ++++++++++++++ api/cars-post/function.json | 6 +++ api/cars-post/index.js | 49 ++++++++++++++++++++ src/App.jsx | 81 +++++++++++++++++++++------------- src/api/cards.js | 25 +++++++++++ src/hooks/useCards.js | 41 +++++++++++++++++ 9 files changed, 249 insertions(+), 31 deletions(-) create mode 100644 api/cards-delete/function.json create mode 100644 api/cards-delete/index.js create mode 100644 api/cards-get/function.json create mode 100644 api/cards-get/index.js create mode 100644 api/cars-post/function.json create mode 100644 api/cars-post/index.js create mode 100644 src/api/cards.js create mode 100644 src/hooks/useCards.js diff --git a/api/cards-delete/function.json b/api/cards-delete/function.json new file mode 100644 index 0000000..8892f9a --- /dev/null +++ b/api/cards-delete/function.json @@ -0,0 +1,6 @@ +{ + "bindings": [ + { "authLevel": "anonymous", "type": "httpTrigger", "direction": "in", "name": "req", "methods": ["delete"], "route": "cards/{id}" }, + { "type": "http", "direction": "out", "name": "res" } + ] +} diff --git a/api/cards-delete/index.js b/api/cards-delete/index.js new file mode 100644 index 0000000..db05163 --- /dev/null +++ b/api/cards-delete/index.js @@ -0,0 +1,32 @@ +import { CosmosClient } from "@azure/cosmos"; + +function getClientPrincipal(req) { + const v = req.headers["x-ms-client-principal"]; + if (!v) return null; + return JSON.parse(Buffer.from(v, "base64").toString("utf8")); +} + +const endpoint = process.env.COSMOS_ENDPOINT; +const key = process.env.COSMOS_KEY; +const databaseId = process.env.COSMOS_DB || "finance"; +const containerId = process.env.COSMOS_CARDS_CONTAINER || "cards"; + +export default async function (context, req) { + try { + const principal = getClientPrincipal(req); + if (!principal) { context.res = { status: 401, jsonBody: { error: "Unauthorized" } }; return; } + const userId = principal.userId; + + const id = context.bindingData?.id || req.query?.id; + if (!id) { context.res = { status: 400, jsonBody: { error: "id requerido" } }; return; } + + const client = new CosmosClient({ endpoint, key }); + const container = client.database(databaseId).container(containerId); + await container.item(id, userId).delete(); + + context.res = { status: 204 }; + } catch (e) { + context.log.error(e); + context.res = { status: 404, jsonBody: { error: "Not found" } }; + } +} diff --git a/api/cards-get/function.json b/api/cards-get/function.json new file mode 100644 index 0000000..eb8deb0 --- /dev/null +++ b/api/cards-get/function.json @@ -0,0 +1,6 @@ +{ + "bindings": [ + { "authLevel": "anonymous", "type": "httpTrigger", "direction": "in", "name": "req", "methods": ["get"], "route": "cards" }, + { "type": "http", "direction": "out", "name": "res" } + ] +} diff --git a/api/cards-get/index.js b/api/cards-get/index.js new file mode 100644 index 0000000..177c161 --- /dev/null +++ b/api/cards-get/index.js @@ -0,0 +1,34 @@ +import { CosmosClient } from "@azure/cosmos"; + +function getClientPrincipal(req) { + const v = req.headers["x-ms-client-principal"]; + if (!v) return null; + return JSON.parse(Buffer.from(v, "base64").toString("utf8")); +} + +const endpoint = process.env.COSMOS_ENDPOINT; +const key = process.env.COSMOS_KEY; +const databaseId = process.env.COSMOS_DB || "finance"; +const containerId = process.env.COSMOS_CARDS_CONTAINER || "cards"; // <-- contenedor para tarjetas + +export default async function (context, req) { + try { + const principal = getClientPrincipal(req); + if (!principal) { context.res = { status: 401, jsonBody: { error: "Unauthorized" } }; return; } + const userId = principal.userId; + + const client = new CosmosClient({ endpoint, key }); + const container = client.database(databaseId).container(containerId); + + const query = "SELECT * FROM c WHERE c.userId = @userId ORDER BY c.createdAt DESC"; + const { resources } = await container.items.query({ + query, + parameters: [{ name: "@userId", value: userId }] + }).fetchAll(); + + context.res = { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(resources ?? []) }; + } catch (e) { + context.log.error(e); + context.res = { status: 500, jsonBody: { error: String(e.message || e) } }; + } +} diff --git a/api/cars-post/function.json b/api/cars-post/function.json new file mode 100644 index 0000000..be33f3d --- /dev/null +++ b/api/cars-post/function.json @@ -0,0 +1,6 @@ +{ + "bindings": [ + { "authLevel": "anonymous", "type": "httpTrigger", "direction": "in", "name": "req", "methods": ["post"], "route": "cards" }, + { "type": "http", "direction": "out", "name": "res" } + ] +} diff --git a/api/cars-post/index.js b/api/cars-post/index.js new file mode 100644 index 0000000..1b5f4fd --- /dev/null +++ b/api/cars-post/index.js @@ -0,0 +1,49 @@ +import { CosmosClient } from "@azure/cosmos"; +import { randomUUID } from "crypto"; + +function getClientPrincipal(req) { + const v = req.headers["x-ms-client-principal"]; + if (!v) return null; + return JSON.parse(Buffer.from(v, "base64").toString("utf8")); +} + +const endpoint = process.env.COSMOS_ENDPOINT; +const key = process.env.COSMOS_KEY; +const databaseId = process.env.COSMOS_DB || "finance"; +const containerId = process.env.COSMOS_CARDS_CONTAINER || "cards"; + +export default async function (context, req) { + try { + const principal = getClientPrincipal(req); + if (!principal) { context.res = { status: 401, jsonBody: { error: "Unauthorized" } }; return; } + const userId = principal.userId; + + const body = req.body || {}; + const { name, limit, balance = 0, cutoffDay = 1, paymentDay = 10 } = body; + if (!name || typeof limit !== "number") { + context.res = { status: 400, jsonBody: { error: "name y limit (number) son requeridos" } }; + return; + } + + const item = { + id: body.id || randomUUID(), + type: "card", + userId, + name, + limit, + balance, + cutoffDay, + paymentDay, + createdAt: new Date().toISOString() + }; + + const client = new CosmosClient({ endpoint, key }); + const container = client.database(databaseId).container(containerId); + const { resource } = await container.items.create(item, { disableAutomaticIdGeneration: true }); + + context.res = { status: 201, jsonBody: resource }; + } catch (e) { + context.log.error(e); + context.res = { status: 500, jsonBody: { error: String(e.message || e) } }; + } +} diff --git a/src/App.jsx b/src/App.jsx index c6dde97..2aef27d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect } from 'react'; import { PlusCircle, Trash2, CreditCard, TrendingUp, TrendingDown, DollarSign, Download, Upload } from 'lucide-react'; import { PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { useTransactions } from './hooks/useTransactions'; +import { useCards } from './hooks/useCards'; import { getUser, login, logout } from './auth'; const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316']; @@ -47,15 +48,14 @@ const FinanceTracker = () => { return () => { mounted = false; }; }, []); - // ====== Datos (el hook SIEMPRE se llama para mantener orden) ====== + // ====== Hooks de datos (SIEMPRE antes de cualquier return) ====== const userId = user?.userId ?? null; const { items, loading, error, create, remove } = useTransactions(userId, queryMonth); + const { cards: creditCards, loading: loadingCards, error: errorCards, create: createCard, remove: removeCard } = useCards(userId); + const isMobile = useIsMobile(); + const chartHeight = isMobile ? 220 : 300; // ====== Estado local UI ====== - const [creditCards, setCreditCards] = useState([ - { id: 1, name: 'Visa Oro', limit: 30000, balance: 15000, cutoffDay: 15, paymentDay: 25 }, - { id: 2, name: 'Mastercard', limit: 20000, balance: 5000, cutoffDay: 10, paymentDay: 20 } - ]); const [newTransaction, setNewTransaction] = useState({ date: new Date().toISOString().split('T')[0], type: 'egreso', @@ -120,6 +120,8 @@ const FinanceTracker = () => { try { const data = JSON.parse(e.target.result); let ok = 0, fail = 0; + + // Transacciones if (Array.isArray(data.transactions)) { const chunks = (arr, size) => arr.reduce((a,_,i)=> (i%size? a[a.length-1].push(arr[i]) : a.push([arr[i]]), a), []); for (const batch of chunks(data.transactions, 5)) { @@ -136,7 +138,25 @@ const FinanceTracker = () => { results.forEach(r => r.status === 'fulfilled' ? ok++ : fail++); } } - if (Array.isArray(data.creditCards)) setCreditCards(data.creditCards); + + // Tarjetas (persistir en Cosmos) + if (Array.isArray(data.creditCards)) { + for (const c of data.creditCards) { + try { + await createCard({ + name: c.name, + limit: Number(c.limit), + balance: Number(c.balance) || 0, + cutoffDay: Number(c.cutoffDay) || 1, + paymentDay: Number(c.paymentDay) || 10 + }); + ok++; + } catch { + fail++; + } + } + } + alert(`Importación completada. Éxitos: ${ok}, Fallos: ${fail}`); } catch (err) { console.error(err); @@ -166,16 +186,13 @@ const FinanceTracker = () => { }, {}); const lineData = Object.values(transactionsByMonth).sort((a, b) => a.month.localeCompare(b.month)); - const totalCreditLimit = creditCards.reduce((s, c) => s + c.limit, 0); - const totalCreditUsed = creditCards.reduce((s, c) => s + c.balance, 0); + const totalCreditLimit = creditCards.reduce((s, c) => s + (c.limit || 0), 0); + const totalCreditUsed = creditCards.reduce((s, c) => s + (c.balance || 0), 0); const creditAvailable = totalCreditLimit - totalCreditUsed; return { totalIngresos, totalEgresos, balance, pieData, lineData, totalCreditLimit, totalCreditUsed, creditAvailable }; }, [transactions, creditCards]); - const isMobile = useIsMobile(); - const chartHeight = isMobile ? 220 : 300; - // ====== Early returns (después de TODOS los hooks) ====== if (loadingUser) return
Cargando sesión…
; if (!user) { window.location.href = '/login'; return null; } @@ -252,6 +269,12 @@ const FinanceTracker = () => { {error &&

Error: {String(error)}

} )} + {activeTab === 'tarjetas' && ( + <> + {loadingCards &&

Cargando tarjetas…

} + {errorCards &&

Error: {String(errorCards)}

} + + )} {activeTab === 'dashboard' && (
@@ -358,18 +381,18 @@ const FinanceTracker = () => {
Límite: - ${card.limit.toLocaleString()} + ${(card.limit || 0).toLocaleString()}
Utilizado: - ${card.balance.toLocaleString()} + ${(card.balance || 0).toLocaleString()}
80 ? 'bg-red-500' : usage > 50 ? 'bg-yellow-500' : 'bg-green-500' }`} - style={{ width: `${usage}%` }} + style={{ width: `${isFinite(usage) ? usage : 0}%` }} />
@@ -552,19 +575,15 @@ const FinanceTracker = () => { className="border border-slate-300 rounded-lg px-3 h-11 text-sm" /> @@ -595,11 +614,11 @@ const FinanceTracker = () => {
Límite total: - ${card.limit.toLocaleString()} + ${(card.limit || 0).toLocaleString()}
Utilizado: - ${card.balance.toLocaleString()} + ${(card.balance || 0).toLocaleString()}
Disponible: @@ -610,7 +629,7 @@ const FinanceTracker = () => { className={`h-2 rounded-full transition-all ${ usage > 80 ? 'bg-red-400' : usage > 50 ? 'bg-yellow-400' : 'bg-green-400' }`} - style={{ width: `${usage}%` }} + style={{ width: `${isFinite(usage) ? usage : 0}%` }} />
diff --git a/src/api/cards.js b/src/api/cards.js new file mode 100644 index 0000000..37c24d1 --- /dev/null +++ b/src/api/cards.js @@ -0,0 +1,25 @@ +export async function listCards() { + const r = await fetch('/api/cards', { credentials: 'include' }); + if (!r.ok) throw new Error('Error al leer tarjetas'); + return r.json(); +} + +export async function addCard(card) { + const r = await fetch('/api/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(card) + }); + if (!r.ok) throw new Error('Error al crear tarjeta'); + return r.json(); +} + +export async function deleteCard(id) { + const r = await fetch(`/api/cards/${encodeURIComponent(id)}`, { + method: 'DELETE', + credentials: 'include' + }); + if (!r.ok && r.status !== 204) throw new Error('Error al borrar tarjeta'); + return true; +} diff --git a/src/hooks/useCards.js b/src/hooks/useCards.js new file mode 100644 index 0000000..7f97327 --- /dev/null +++ b/src/hooks/useCards.js @@ -0,0 +1,41 @@ +import { useEffect, useState, useCallback } from 'react'; +import * as api from '../api/cards'; + +export function useCards(userId) { + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + if (!userId) { setCards([]); return; } + setLoading(true); setError(null); + try { + const data = await api.listCards(); + if (!cancelled) setCards(data); + } catch (e) { + if (!cancelled) setError(e); + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { cancelled = true; }; + }, [userId]); + + const create = useCallback(async (card) => { + if (!userId) throw new Error('No usuario'); + const c = await api.addCard(card); + setCards(prev => [c, ...prev]); + return c; + }, [userId]); + + const remove = useCallback(async (id) => { + if (!userId) throw new Error('No usuario'); + await api.deleteCard(id); + setCards(prev => prev.filter(x => x.id !== id)); + }, [userId]); + + return { cards, loading, error, create, remove }; +}