Skip to content

Commit 46996a9

Browse files
committed
feat: add transferences to ui
1 parent 3281498 commit 46996a9

6 files changed

Lines changed: 163 additions & 27 deletions

File tree

src/api/server.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
CategoryRead,
2828
Expense as DBExpense,
2929
ExpenseRead,
30+
Transference as DBTransference,
31+
TransferenceRead,
3032
)
3133
from db.models import (
3234
ExpenseCreate,
@@ -133,6 +135,21 @@ def serialize_expense(expense: DBExpense, category: Category) -> ExpenseRead:
133135
)
134136

135137

138+
def serialize_transference(transference: DBTransference) -> TransferenceRead:
139+
return TransferenceRead(
140+
id=transference.id,
141+
user_id=transference.user_id,
142+
recipient=transference.recipient,
143+
amount=transference.amount,
144+
currency=transference.currency,
145+
category=transference.category,
146+
category_slug=transference.category.value,
147+
category_name=transference.category.value.replace("_", " ").title(),
148+
date=transference.date,
149+
description=transference.description,
150+
)
151+
152+
136153
def require_user_id(user: User) -> int:
137154
if user.id is None:
138155
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Authenticated user is invalid")
@@ -396,6 +413,32 @@ def delete_expense(
396413
return JSONResponse(content={"msg": "OK"})
397414

398415

416+
@app.get("/transferences", response_model=list[TransferenceRead])
417+
def get_transferences(
418+
current_user: User = Depends(get_current_user), session: Session = Depends(get_session)
419+
) -> list[TransferenceRead]:
420+
"""Get all transferences for the current user."""
421+
user_id = require_user_id(current_user)
422+
transferences = session.exec(select(DBTransference).where(DBTransference.user_id == user_id)).all()
423+
return [serialize_transference(t) for t in transferences]
424+
425+
426+
@app.delete("/transferences/{id}")
427+
def delete_transference(
428+
id: int, current_user: User = Depends(get_current_user), session: Session = Depends(get_session)
429+
) -> JSONResponse:
430+
"""Delete a transference for the current user."""
431+
user_id = require_user_id(current_user)
432+
transference = session.exec(
433+
select(DBTransference).where(DBTransference.user_id == user_id, DBTransference.id == id)
434+
).first()
435+
if not transference:
436+
raise HTTPException(detail=f"Transference not found: {id}", status_code=status.HTTP_404_NOT_FOUND)
437+
session.delete(transference)
438+
session.commit()
439+
return JSONResponse(content={"msg": "OK"})
440+
441+
399442
@app.post("/email/scan", status_code=status.HTTP_202_ACCEPTED)
400443
def trigger_manual_email_scan(current_user: User = Depends(get_current_user)) -> JSONResponse:
401444
"""

src/db/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ class ExpenseRead(SQLModel):
172172
description: str | None = None
173173

174174

175+
class TransferenceRead(SQLModel):
176+
id: int
177+
user_id: int
178+
recipient: str
179+
amount: float
180+
currency: Currency
181+
category: ExpenseCategory
182+
category_slug: str
183+
category_name: str
184+
date: datetime
185+
description: str | None = None
186+
187+
175188
class UserCreate(SQLModel):
176189
username: str
177190
email: EmailStr

src/parsers/regex.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"amountWithdrawal": "Monto\\s+>\\s+\\$([\\d.]+)",
3434
"date": "\\d{1,2}[-/]\\d{1,2}[-/]\\d{4}",
3535
"commerce": "Compa[^>]+>\\s*([A-Za-z0-9\\s]+?)\\s*=09",
36-
"transferenceRecipient": "Datos de destino[\\s\\S]*?Nombre\\s+([A-Za-z\\s]+?)\\s+RUT",
36+
"transferenceRecipient": "Datos de destino[\\s\\S]*?Nombre\\s+([A-Za-zÁÉÍÓÚáéíóúÑñ\\s]+?)\\s+RUT",
3737
"transferenceMatches": ""
3838
}
3939
}

web/app/components/expenses/ExpenseList.tsx

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { useQuery } from '@tanstack/react-query';
2-
import { expensesApi } from '../../lib/api';
2+
import { expensesApi, transferencesApi } from '../../lib/api';
33
import { DEFAULT_LOCALE, getExpenseCategoryName } from '../../lib/categories';
4-
import type { Expense } from '../../types/models';
4+
import type { Expense, Transference } from '../../types/models';
55
import { useMemo } from 'react';
66
import { queryClient } from '../../lib/queryClient';
77

8+
type Transaction =
9+
| (Expense & { type: 'expense' })
10+
| (Transference & { type: 'transference' });
11+
812
const CATEGORY_COLORS: Record<string, string> = {
913
FOOD: 'bg-orange-500/20 text-orange-400',
1014
EXTERNAL_FOOD: 'bg-orange-500/20 text-orange-400',
@@ -22,31 +26,54 @@ const CATEGORY_COLORS: Record<string, string> = {
2226
ONLINE: 'bg-indigo-500/20 text-indigo-400',
2327
TRAVEL: 'bg-teal-500/20 text-teal-400',
2428
SPORTS: 'bg-lime-500/20 text-lime-400',
29+
LOAN: 'bg-yellow-500/20 text-yellow-400',
30+
FAMILY: 'bg-rose-500/20 text-rose-400',
2531
GENERAL: 'bg-slate-500/20 text-slate-400',
2632
};
2733

28-
async function handleDelete(expenseId: number) {
34+
async function handleDeleteExpense(expenseId: number) {
2935
if (!window.confirm('Quieres eliminar este gasto?')) return;
3036
try {
31-
await expensesApi.delete(expenseId);
37+
await expensesApi.delete(expenseId);
3238
queryClient.invalidateQueries({ queryKey: ['expenses'] });
3339
} catch (error) {
3440
console.error('Failed to delete expense:', error);
3541
alert('No se pudo eliminar el gasto. Intentalo de nuevo.');
3642
}
3743
}
3844

45+
async function handleDeleteTransference(transferenceId: number) {
46+
if (!window.confirm('Quieres eliminar esta transferencia?')) return;
47+
try {
48+
await transferencesApi.delete(transferenceId);
49+
queryClient.invalidateQueries({ queryKey: ['transferences'] });
50+
} catch (error) {
51+
console.error('Failed to delete transference:', error);
52+
alert('No se pudo eliminar la transferencia. Intentalo de nuevo.');
53+
}
54+
}
55+
3956
export function ExpenseList() {
40-
const { data: expenses = [], isLoading, error } = useQuery({
57+
const { data: expenses = [], isLoading: loadingExpenses, error: expensesError } = useQuery({
4158
queryKey: ['expenses'],
4259
queryFn: expensesApi.getAll,
4360
});
4461

45-
// Derived + memoized sorted list (newest first)
46-
const sortedExpenses = useMemo(() => {
47-
if (!expenses.length) return [];
48-
return [...expenses].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
49-
}, [expenses]);
62+
const { data: transferences = [], isLoading: loadingTransferences, error: transferencesError } = useQuery({
63+
queryKey: ['transferences'],
64+
queryFn: transferencesApi.getAll,
65+
});
66+
67+
const isLoading = loadingExpenses || loadingTransferences;
68+
const error = expensesError || transferencesError;
69+
70+
// Combine and sort all transactions (newest first)
71+
const sortedTransactions = useMemo(() => {
72+
const expensesWithType: Transaction[] = expenses.map((e) => ({ ...e, type: 'expense' as const }));
73+
const transferencesWithType: Transaction[] = transferences.map((t) => ({ ...t, type: 'transference' as const }));
74+
const all = [...expensesWithType, ...transferencesWithType];
75+
return all.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
76+
}, [expenses, transferences]);
5077

5178
const formatCurrency = (amount: number, currency: string) => {
5279
const normalizedCurrency = currency.toUpperCase();
@@ -91,56 +118,81 @@ export function ExpenseList() {
91118
);
92119
}
93120

94-
if (sortedExpenses.length === 0) {
121+
if (sortedTransactions.length === 0) {
95122
return (
96123
<div className="bg-gray-800 rounded-lg border border-gray-700 p-8">
97124
<div className="text-center text-gray-500">
98-
Aun no hay gastos. Agrega el primero o conecta Gmail para importarlos automaticamente.
125+
Aun no hay transacciones. Agrega la primera o conecta Gmail para importarlas automaticamente.
99126
</div>
100127
</div>
101128
);
102129
}
103130

131+
const getTransactionTitle = (transaction: Transaction) => {
132+
if (transaction.type === 'expense') {
133+
return transaction.commerce;
134+
}
135+
return transaction.recipient || 'Transferencia';
136+
};
137+
138+
const getTransactionCategoryName = (transaction: Transaction) => {
139+
if (transaction.type === 'expense') {
140+
return getExpenseCategoryName(transaction);
141+
}
142+
return transaction.category_name;
143+
};
144+
104145
return (
105146
<div className="bg-gray-800 rounded-lg border border-gray-700">
106147
<div className="p-4 border-b border-gray-700">
107148
<h2 className="text-lg font-semibold text-white">Transacciones recientes</h2>
108-
<p className="text-sm text-gray-400">{sortedExpenses.length} total</p>
149+
<p className="text-sm text-gray-400">{sortedTransactions.length} total</p>
109150
</div>
110151

111-
<div className="divide-y divide-gray-700 max-h-125 overflow-y-auto"> {/* ← fixed height example */}
112-
{sortedExpenses.map((expense: Expense) => (
152+
<div className="divide-y divide-gray-700 max-h-125 overflow-y-auto">
153+
{sortedTransactions.map((transaction: Transaction) => (
113154
<div
114-
key={expense.id}
155+
key={`${transaction.type}-${transaction.id}`}
115156
className="p-4 hover:bg-gray-750 transition-colors"
116157
>
117158
<div className="flex justify-between items-start gap-3">
118159
<div className="min-w-0 flex-1">
119-
<h3 className="font-medium text-white truncate">{expense.commerce}</h3>
160+
<div className="flex items-center gap-2">
161+
<h3 className="font-medium text-white truncate">{getTransactionTitle(transaction)}</h3>
162+
{transaction.type === 'transference' && (
163+
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
164+
Transferencia
165+
</span>
166+
)}
167+
</div>
120168
<div className="flex items-center gap-2 mt-1">
121169
<span
122-
className={`text-xs px-2 py-0.5 rounded-full ${getCategoryStyle(expense.category_slug)}`}
170+
className={`text-xs px-2 py-0.5 rounded-full ${getCategoryStyle(transaction.category_slug)}`}
123171
>
124-
{getExpenseCategoryName(expense)}
172+
{getTransactionCategoryName(transaction)}
125173
</span>
126-
<span className="text-xs text-gray-500">{formatDate(expense.date)}</span>
174+
<span className="text-xs text-gray-500">{formatDate(transaction.date)}</span>
127175
</div>
128-
{expense.description && (
129-
<p className="text-sm text-gray-500 mt-1 truncate">{expense.description}</p>
176+
{transaction.description && (
177+
<p className="text-sm text-gray-500 mt-1 truncate">{transaction.description}</p>
130178
)}
131179
</div>
132180

133181
<div className="text-right shrink-0">
134182
<p className="font-semibold text-white">
135-
{formatCurrency(expense.amount, expense.currency)}
183+
{formatCurrency(transaction.amount, transaction.currency)}
136184
</p>
137-
<p className="text-xs text-gray-500">{expense.currency.toUpperCase()}</p>
185+
<p className="text-xs text-gray-500">{transaction.currency.toUpperCase()}</p>
138186
</div>
139187

140188
<button
141-
onClick={() => handleDelete(expense.id)}
189+
onClick={() =>
190+
transaction.type === 'expense'
191+
? handleDeleteExpense(transaction.id)
192+
: handleDeleteTransference(transaction.id)
193+
}
142194
className="text-red-400 hover:text-red-300 transition-colors"
143-
title="Eliminar gasto"
195+
title={transaction.type === 'expense' ? 'Eliminar gasto' : 'Eliminar transferencia'}
144196
>
145197
<svg
146198
xmlns="http://www.w3.org/2000/svg"

web/app/lib/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
EmailScanResponse,
77
Expense,
88
GenericResponse,
9+
Transference,
910
User,
1011
UserLoginResponse,
1112
UserUpdate,
@@ -101,6 +102,19 @@ export const expensesApi = {
101102
},
102103
};
103104

105+
// Transferences API
106+
export const transferencesApi = {
107+
getAll: async (): Promise<Transference[]> => {
108+
const response = await api.get<Transference[]>("/transferences");
109+
return response.data;
110+
},
111+
112+
delete: async (id: Number): Promise<GenericResponse> => {
113+
const response = await api.delete<GenericResponse>(`/transferences/${id}`);
114+
return response.data;
115+
},
116+
};
117+
104118
export const categoriesApi = {
105119
getAll: async (): Promise<Category[]> => {
106120
const response = await api.get<Category[]>("/categories");

web/app/types/models.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ export interface CreateExpenseRequest {
6262
description?: string;
6363
}
6464

65+
// Transference types
66+
export interface Transference {
67+
id: number;
68+
user_id: number;
69+
recipient: string;
70+
amount: number;
71+
currency: string;
72+
category: string;
73+
category_slug: string;
74+
category_name: string;
75+
date: string;
76+
description?: string;
77+
}
78+
6579
export interface Category {
6680
id: number;
6781
slug: string;

0 commit comments

Comments
 (0)