Compare commits

...

6 Commits

Author SHA1 Message Date
Carlos Escalante
5612f69f11 Record Phase 4 iteration 1 in PROGRESS.md
All checks were successful
Deploy to VPS / test (push) Successful in 1m47s
Deploy to VPS / deploy (push) Successful in 1m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:04:33 -06:00
Carlos Escalante
b3a7f5185a Polish: aria-pressed toggles, deferred row styling, override hint
Fund visibility toggles and the header privacy/theme buttons expose
aria-pressed (FE-16); deferred transactions render struck-through and
muted so the state is visible at a glance (UX-05); the balance-override
input documents its keyboard contract (UX-21). The Budget CSV button
rides in the transactions tab header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
629d1181ae Undo grace for transaction deletes (UX-10)
After confirming a delete the row disappears immediately but the API
call waits 5 seconds behind a Deshacer toast action; undo restores the
row without any server round-trip. A failed commit restores the row
and reports the error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
571428f5ac CSV export for transactions with download buttons (ARCH-18)
GET /api/v1/transactions/export streams a CSV (optional source/type/
date filters), cookie-authenticated so window.open downloads work.
Buttons on the Budget transactions tab (all) and Salarios (SALARY
only). Quoting and category resolution covered by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
4ceb67564a Sync Status: per-source ingestion health page with nav warning badge
The n8n flows fail invisibly from the app's perspective (review UX-17,
the top trust gap). /api/v1/sync-status derives last-received + cadence
thresholds for BAC, salary, municipal, pensions and exchange rate from
existing data — no n8n integration needed. New Sincronización page
shows per-source freshness and a hint to check n8n; the sidebar item
gets an amber dot when any source is stale. Verified live against the
dev DB (correctly flags its stale BAC data at 43 days).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
8b7f3dff40 Localization sweep: shared es-CR dates module, no more en-US strays
src/lib/dates.ts is the single source for month names and date
formatting; the three per-page month arrays are gone (with an
off-by-one guard — the shared array is 1-indexed). formatDate and the
Analytics chart tooltips now speak es-CR, Analytics headings are
Spanish, and the cycle selector says 'Todo el período' (UX-19, FE-17,
UX-07).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:59:16 -06:00
22 changed files with 629 additions and 48 deletions

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.auth import get_current_user
from app.db import get_session
from app.services.sync_status import compute_sync_status
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
@router.get("/")
def sync_status(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
sources = compute_sync_status(session)
return {
"sources": sources,
"warnings": sum(1 for s in sources if s["status"] in ("warning", "never")),
}

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response as FastAPIResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from sqlmodel import Session, col, func, select from sqlmodel import Session, col, func, select
@@ -21,6 +22,7 @@ from app.models.models import (
) )
from app.services.budget_projection import get_cycle_range, get_previous_cycle from app.services.budget_projection import get_cycle_range, get_previous_cycle
from app.services.csv_export import build_transactions_csv
from app.services.exchange_rate import get_converted_amount_expr from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -97,6 +99,33 @@ def list_transactions(
return session.exec(query).all() return session.exec(query).all()
@router.get("/export")
def export_transactions_csv(
source: Optional[TransactionSource] = None,
transaction_type: Optional[TransactionType] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Download transactions as CSV (filters optional). Cookie-authenticated,
so a plain <a href> download works from the SPA."""
csv_text = build_transactions_csv(
session,
source=source,
transaction_type=transaction_type,
start_date=datetime.fromisoformat(start_date) if start_date else None,
end_date=datetime.fromisoformat(end_date) if end_date else None,
)
return FastAPIResponse(
content=csv_text,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="wealthysmart-transactions.csv"'
},
)
@router.get("/cycles", response_model=list[BillingCycle]) @router.get("/cycles", response_model=list[BillingCycle])
def list_billing_cycles( def list_billing_cycles(
session: Session = Depends(get_session), session: Session = Depends(get_session),

View File

@@ -14,6 +14,7 @@ from app.api.v1.endpoints import (
salarios, salarios,
savings_accrual, savings_accrual,
settings, settings,
sync_status,
tokens, tokens,
transactions, transactions,
) )
@@ -34,3 +35,4 @@ api_router.include_router(salarios.router)
api_router.include_router(pensions.router) api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router) api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router) api_router.include_router(savings_accrual.router)
api_router.include_router(sync_status.router)

View File

@@ -0,0 +1,57 @@
"""CSV export of transactions (review ARCH-18 / wishlist #4)."""
import csv
import io
from datetime import datetime
from typing import Optional
from sqlmodel import Session, col, select
from app.models.models import Transaction, TransactionSource, TransactionType
COLUMNS = [
"id", "date", "merchant", "city", "amount", "currency",
"transaction_type", "source", "bank", "category", "notes", "reference",
]
def build_transactions_csv(
session: Session,
*,
source: Optional[TransactionSource] = None,
transaction_type: Optional[TransactionType] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
) -> str:
q = select(Transaction)
if source:
q = q.where(Transaction.source == source)
if transaction_type:
q = q.where(Transaction.transaction_type == transaction_type)
if start_date:
q = q.where(Transaction.date >= start_date)
if end_date:
q = q.where(Transaction.date < end_date)
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(COLUMNS)
for tx in session.exec(q).all():
writer.writerow(
[
tx.id,
tx.date.isoformat(),
tx.merchant,
tx.city or "",
f"{tx.amount:.2f}",
tx.currency.value,
tx.transaction_type.value,
tx.source.value,
tx.bank.value,
tx.category.name if tx.category else "",
tx.notes or "",
tx.reference or "",
]
)
return buf.getvalue()

View File

@@ -0,0 +1,94 @@
"""Per-source ingestion health, derived from existing data (review UX-17).
The n8n flows fail silently from the app's perspective — a parser break just
looks like missing data. This derives "when did we last receive something from
each source" plus a cadence threshold, so a stalled flow becomes visible
in-app without any n8n integration.
"""
from dataclasses import dataclass
from sqlmodel import Session, func, select
from app.models.models import (
Bank,
ExchangeRate,
MunicipalReceipt,
PensionSnapshot,
Transaction,
TransactionSource,
TransactionType,
)
from app.timeutil import utcnow
@dataclass
class SourceSpec:
key: str
label: str
warn_after_days: float
description: str
# Cadences: BAC emails arrive near-daily with normal card use; salary is
# biweekly/monthly; municipal and pension are monthly; rates refresh every 6h.
SOURCES: list[SourceSpec] = [
SourceSpec("bac_credit_card", "BAC Tarjeta de Crédito", 7,
"Correos de compra parseados por n8n"),
SourceSpec("salary", "Depósitos de Salario", 35,
"Correos de depósito parseados por n8n"),
SourceSpec("municipal", "Recibos Municipales", 40,
"PDF mensual vía n8n (cron)"),
SourceSpec("pensions", "Estados de Pensión", 40,
"PDFs diarios/mensuales vía n8n"),
SourceSpec("exchange_rate", "Tipo de Cambio", 1,
"Actualización automática cada 6 horas"),
]
def _last_received(session: Session, key: str):
if key == "bac_credit_card":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.bank == Bank.BAC,
)
).one()
if key == "salary":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.transaction_type == TransactionType.SALARY
)
).one()
if key == "municipal":
return session.exec(select(func.max(MunicipalReceipt.created_at))).one()
if key == "pensions":
return session.exec(select(func.max(PensionSnapshot.created_at))).one()
if key == "exchange_rate":
return session.exec(select(func.max(ExchangeRate.fetched_at))).one()
raise ValueError(f"unknown sync source: {key}")
def compute_sync_status(session: Session) -> list[dict]:
now = utcnow()
out: list[dict] = []
for spec in SOURCES:
last = _last_received(session, spec.key)
if last is None:
status = "never"
age_days = None
else:
age_days = (now - last).total_seconds() / 86400
status = "ok" if age_days <= spec.warn_after_days else "warning"
out.append(
{
"key": spec.key,
"label": spec.label,
"description": spec.description,
"last_received": last.isoformat() if last else None,
"age_days": round(age_days, 1) if age_days is not None else None,
"warn_after_days": spec.warn_after_days,
"status": status,
}
)
return out

View File

@@ -0,0 +1,112 @@
"""Sync-status derivation (UX-17) and CSV export (ARCH-18)."""
import csv
import io
from datetime import datetime, timedelta
from decimal import Decimal
from app.models.models import (
Bank,
Category,
Currency,
ExchangeRate,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.csv_export import build_transactions_csv
from app.services.sync_status import compute_sync_status
from app.timeutil import utcnow
def by_key(statuses: list[dict]) -> dict[str, dict]:
return {s["key"]: s for s in statuses}
class TestSyncStatus:
def test_empty_db_reports_never(self, session):
statuses = by_key(compute_sync_status(session))
assert len(statuses) == 5
assert all(s["status"] == "never" for s in statuses.values())
assert all(s["last_received"] is None for s in statuses.values())
def test_fresh_data_is_ok_and_stale_warns(self, session):
fresh = Transaction(
amount=Decimal("1"),
merchant="X",
date=utcnow(),
source=TransactionSource.CREDIT_CARD,
bank=Bank.BAC,
)
fresh.created_at = utcnow() - timedelta(days=1)
stale_salary = Transaction(
amount=Decimal("1"),
merchant="EMPLOYER",
date=utcnow(),
transaction_type=TransactionType.SALARY,
source=TransactionSource.TRANSFER,
)
stale_salary.created_at = utcnow() - timedelta(days=60)
session.add(fresh)
session.add(stale_salary)
session.add(
ExchangeRate(
date=utcnow(),
buy_rate=Decimal("500"),
sell_rate=Decimal("510"),
)
)
session.commit()
statuses = by_key(compute_sync_status(session))
assert statuses["bac_credit_card"]["status"] == "ok"
assert statuses["bac_credit_card"]["age_days"] == 1.0
assert statuses["salary"]["status"] == "warning"
assert statuses["exchange_rate"]["status"] == "ok"
assert statuses["municipal"]["status"] == "never"
class TestCsvExport:
def _seed(self, session):
cat = Category(name="Food")
session.add(cat)
session.commit()
session.refresh(cat)
session.add(
Transaction(
amount=Decimal("1500.50"),
merchant="SODA, LA \"BUENA\"", # exercises CSV quoting
date=datetime(2026, 4, 2),
category_id=cat.id,
currency=Currency.CRC,
)
)
session.add(
Transaction(
amount=Decimal("10"),
merchant="CASHTHING",
date=datetime(2026, 4, 3),
source=TransactionSource.CASH,
)
)
session.commit()
def test_full_export_with_quoting_and_category(self, session):
self._seed(session)
text = build_transactions_csv(session)
rows = list(csv.reader(io.StringIO(text)))
assert rows[0][:3] == ["id", "date", "merchant"]
assert len(rows) == 3 # header + 2
newest_first = rows[1]
assert newest_first[2] == "CASHTHING"
soda = rows[2]
assert soda[2] == 'SODA, LA "BUENA"' # round-trips through quoting
assert soda[4] == "1500.50"
assert soda[9] == "Food"
def test_source_filter(self, session):
self._seed(session)
text = build_transactions_csv(session, source=TransactionSource.CASH)
rows = list(csv.reader(io.StringIO(text)))
assert len(rows) == 2
assert rows[1][2] == "CASHTHING"

View File

@@ -145,4 +145,29 @@ fallback already guarded `len >= 2`).
## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED ## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED
## Phase 4 — UX, Trust & Features ⏳ NOT STARTED ## Phase 4 — UX, Trust & Features 🔄 ITERATION 1 DONE
Iteration 1 completed 2026-06-10. The plan marks 4.x items independent; this
iteration delivered the highest-impact slice.
| Plan item | Status | Commit |
|---|---|---|
| 4.1 Sync Status page + nav badge (UX-17, the #1 trust feature) | ✅ | `4ceb675` |
| 4.3 Localization: shared es-CR dates module, stray labels | ✅ | (l10n) |
| 4.5 Undo grace (5s + Deshacer) for transaction deletes | ✅ | `629d118` |
| 4.8 CSV export endpoint + Budget/Salarios buttons | ✅ | `571428f` |
| 4.7 picks: aria-pressed, deferred row styling, override hint | ✅ | `b3a7f51` |
**Sync Status design:** per-source health derived entirely from existing data
(`max(created_at)` per source + cadence thresholds: BAC 7d, salary 35d,
municipal/pensions 40d, exchange rate 1d) — no n8n integration required for v1.
Endpoint tested; verified live (it immediately flagged the dev DB's stale data).
**Deferred to iteration 2** (each independent): 4.2 import review & dedup
transparency (L — includes the reference-hash tightening, which must migrate
existing hashes to avoid breaking dedup), 4.4 transaction search-everywhere is
**not needed** (search already applies to all sources — UX-04 was a false
positive) but bulk operations remain (L), 4.6 mobile card layout (M), 4.7
remainder: shared period navigator, editable pension assumptions, cycle filter
on all Analytics charts, empty-state actions, dirty-form guard, breadcrumb,
meter labels. 4.9 wishlist unscheduled.

View File

@@ -23,6 +23,7 @@ import Salarios from "./pages/Salarios";
import Pensions from "./pages/Pensions"; import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones"; import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales"; import ServiciosMunicipales from "./pages/ServiciosMunicipales";
import SyncStatus from "./pages/SyncStatus";
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
@@ -56,6 +57,7 @@ function AppRoutes() {
<Route path="/salarios" element={<Salarios />} /> <Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} /> <Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} /> <Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/sync" element={<SyncStatus />} />
<Route path="/transactions" element={<Navigate to="/budget" replace />} /> <Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} /> <Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route> </Route>

View File

@@ -49,7 +49,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All time</SelectItem> <SelectItem value="all">Todo el período</SelectItem>
{cycles.map((c) => ( {cycles.map((c) => (
<SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}> <SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
{c.label} ({c.count}) {c.label} ({c.count})

View File

@@ -11,12 +11,16 @@ import {
TrendingUp, TrendingUp,
Wallet, Wallet,
Menu, Menu,
RefreshCw,
Sun, Sun,
Moon, Moon,
Eye, Eye,
EyeOff, EyeOff,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getSyncStatus } from "@/lib/api";
import { useTheme } from "@/contexts/theme-context"; import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context"; import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext"; import { useAuth } from "@/AuthContext";
@@ -39,7 +43,10 @@ interface NavSection {
const navSections: NavSection[] = [ const navSections: NavSection[] = [
{ {
label: "General", label: "General",
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }], items: [
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
],
}, },
{ {
label: "Finanzas", label: "Finanzas",
@@ -64,6 +71,14 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const isActive = (to: string) => const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`); pathname === to || pathname.startsWith(`${to}/`);
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return ( return (
<nav className="flex flex-col gap-0.5 px-3"> <nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => ( {navSections.map((section) => (
@@ -85,6 +100,13 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
{label} {label}
{to === "/sync" && syncWarnings > 0 && (
<span
className="ml-auto w-2 h-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
)}
</Link> </Link>
))} ))}
</div> </div>
@@ -129,10 +151,10 @@ export default function Layout() {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode"> <Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode" aria-pressed={privacyMode}>
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button> </Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme"> <Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme" aria-pressed={theme === "dark"}>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} {theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button> </Button>
<Button <Button

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useMemo, useRef, useState } from 'react';
import { import {
Plus, Plus,
Search, Search,
@@ -56,34 +56,70 @@ export default function TransactionList({
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false); const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set());
const deleteTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const handleEdit = (tx: Transaction) => { const handleEdit = (tx: Transaction) => {
setEditing(tx); setEditing(tx);
setModalOpen(true); setModalOpen(true);
}; };
const handleDelete = async () => { const commitDelete = async (id: number) => {
if (deleteId === null) return; deleteTimers.current.delete(id);
setDeleting(true);
try { try {
await api.delete(`/transactions/${deleteId}`); await api.delete(`/transactions/${id}`);
toast.success('Transacción eliminada');
setDeleteId(null);
onRefresh(); onRefresh();
} catch { } catch {
toast.error('No se pudo eliminar la transacción'); toast.error('No se pudo eliminar la transacción');
} finally { setPendingDeletes((prev) => {
setDeleting(false); const next = new Set(prev);
next.delete(id);
return next;
});
} }
}; };
// Undo grace: the row disappears immediately, the API call fires after 5s
// unless the user clicks Deshacer (UX-10).
const handleDelete = () => {
if (deleteId === null) return;
const id = deleteId;
setDeleteId(null);
setPendingDeletes((prev) => new Set(prev).add(id));
const toastId = toast('Transacción eliminada', {
duration: 5000,
action: {
label: 'Deshacer',
onClick: () => {
const timer = deleteTimers.current.get(id);
if (timer) clearTimeout(timer);
deleteTimers.current.delete(id);
setPendingDeletes((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
},
},
});
deleteTimers.current.set(
id,
setTimeout(() => {
toast.dismiss(toastId);
void commitDelete(id);
}, 5000),
);
};
const columns = useMemo( const columns = useMemo(
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }), () => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
[showCategory, showSourceIcon, onToggleDeferred], [showCategory, showSourceIcon, onToggleDeferred],
); );
const empty = transactions.length === 0 && !loading; const visibleTransactions = pendingDeletes.size
? transactions.filter((t) => !pendingDeletes.has(t.id))
: transactions;
const empty = visibleTransactions.length === 0 && !loading;
return ( return (
<> <>
@@ -195,7 +231,7 @@ export default function TransactionList({
<CardContent className="p-0"> <CardContent className="p-0">
<DataTable <DataTable
columns={columns} columns={columns}
data={transactions} data={visibleTransactions}
pagination pagination
pageSize={25} pageSize={25}
initialSorting={[{ id: 'date', desc: true }]} initialSorting={[{ id: 'date', desc: true }]}
@@ -220,7 +256,6 @@ export default function TransactionList({
message="This transaction will be permanently deleted. This action cannot be undone." message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete} onConfirm={handleDelete}
onCancel={() => setDeleteId(null)} onCancel={() => setDeleteId(null)}
loading={deleting}
/> />
)} )}
</> </>

View File

@@ -1,4 +1,5 @@
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES } from '@/lib/dates';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
@@ -15,10 +16,6 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
const MONTH_NAMES = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const FRESH_START_YEAR = 2026; const FRESH_START_YEAR = 2026;
const FRESH_START_MONTH = 3; const FRESH_START_MONTH = 3;
@@ -192,6 +189,8 @@ export default function YearlyOverview({
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave} onBlur={handleSave}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
title="Enter para guardar · vacío para quitar el ajuste · Esc para cancelar"
aria-description="Enter guarda; vacío quita el ajuste"
className="h-7 w-36 text-right font-mono text-sm ml-auto" className="h-7 w-36 text-right font-mono text-sm ml-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />

View File

@@ -107,6 +107,7 @@ export function getTransactionColumns({
className={cn( className={cn(
'font-mono font-medium', 'font-mono font-medium',
tx.transaction_type !== 'COMPRA' && 'text-primary', tx.transaction_type !== 'COMPRA' && 'text-primary',
tx.deferred_to_next_cycle && 'opacity-50 line-through decoration-muted-foreground/60',
)} )}
> >
{tx.transaction_type === 'COMPRA' ? '-' : '+'} {tx.transaction_type === 'COMPRA' ? '-' : '+'}

View File

@@ -485,3 +485,22 @@ export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", { api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
params: months ? { months } : undefined, params: months ? { months } : undefined,
}); });
// --- Sync Status ---
export interface SyncSource {
key: string;
label: string;
description: string;
last_received: string | null;
age_days: number | null;
warn_after_days: number;
status: 'ok' | 'warning' | 'never';
}
export interface SyncStatusResponse {
sources: SyncSource[];
warnings: number;
}
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');

28
frontend/src/lib/dates.ts Normal file
View File

@@ -0,0 +1,28 @@
/** Single source of truth for date display — the app speaks es-CR (UX-19). */
export const MONTH_NAMES_ES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
export const MONTH_NAMES_ES_SHORT = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
/** "2 abr" */
export function formatShortDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('es-CR', {
month: 'short',
day: 'numeric',
});
}
/** "hace 3 días" / "hace 2 horas" / "hace un momento" */
export function formatRelativeAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const hours = ms / 3_600_000;
if (hours < 1) return 'hace un momento';
if (hours < 48) return `hace ${Math.round(hours)} h`;
return `hace ${Math.round(hours / 24)} días`;
}

View File

@@ -1,3 +1,5 @@
import { formatShortDate } from './dates';
export function formatAmount(amount: number, currency: string) { export function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount); const abs = Math.abs(amount);
if (currency === 'BTC') return abs.toFixed(8); if (currency === 'BTC') return abs.toFixed(8);
@@ -17,5 +19,5 @@ export function formatLocalDatetime(d: Date): string {
} }
export function formatDate(dateStr: string) { export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return formatShortDate(dateStr);
} }

View File

@@ -65,7 +65,7 @@ const trendChartConfig = {
const dailyChartConfig = { const dailyChartConfig = {
total: { total: {
label: 'Daily Spending', label: 'Gasto Diario',
color: 'var(--chart-2)', color: 'var(--chart-2)',
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
@@ -127,7 +127,7 @@ export default function Analytics() {
<BarChart3 className="w-5 h-5 text-primary" /> <BarChart3 className="w-5 h-5 text-primary" />
<h1 className="text-2xl font-bold font-heading">Analytics</h1> <h1 className="text-2xl font-bold font-heading">Analytics</h1>
</div> </div>
<p className="text-sm text-muted-foreground mt-1">Spending breakdown and trends</p> <p className="text-sm text-muted-foreground mt-1">Desglose y tendencias de gasto</p>
</div> </div>
<BillingCycleSelector value={cycle} onChange={setCycle} /> <BillingCycleSelector value={cycle} onChange={setCycle} />
</div> </div>
@@ -150,7 +150,7 @@ export default function Analytics() {
<CardContent> <CardContent>
{byCategory.length === 0 ? ( {byCategory.length === 0 ? (
<div className="h-64 flex items-center justify-center text-muted-foreground text-sm"> <div className="h-64 flex items-center justify-center text-muted-foreground text-sm">
No data for this period Sin datos para este período
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@@ -248,7 +248,7 @@ export default function Analytics() {
<CardContent> <CardContent>
{daily.length === 0 ? ( {daily.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm"> <div className="h-48 flex items-center justify-center text-muted-foreground text-sm">
No data for this period Sin datos para este período
</div> </div>
) : ( ) : (
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full"> <ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
@@ -272,7 +272,7 @@ export default function Analytics() {
<ChartTooltipContent <ChartTooltipContent
formatter={(value) => formatCRC(Number(value))} formatter={(value) => formatCRC(Number(value))}
labelFormatter={(label) => labelFormatter={(label) =>
new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) new Date(label).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })
} }
/> />
} }

View File

@@ -1,9 +1,10 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react'; import { ChevronLeft, ChevronRight, Calculator, Download } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { type Transaction } from '@/lib/api'; import api, { type Transaction } from '@/lib/api';
import { MONTH_NAMES_ES as MONTH_NAMES } from '@/lib/dates';
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -12,10 +13,6 @@ import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager'; import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList'; import TransactionList from '@/components/TransactionList';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const MIN_YEAR = 2026; const MIN_YEAR = 2026;
const MAX_YEAR = 2030; const MAX_YEAR = 2030;
@@ -174,15 +171,26 @@ export default function Budget() {
</TabsContent> </TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4"> <TabsContent value="transactions" className="space-y-3 mt-4">
<Tabs <div className="flex items-center justify-between gap-2">
value={txSource} <Tabs
onValueChange={(v) => setTxSource(v as typeof txSource)} value={txSource}
> onValueChange={(v) => setTxSource(v as typeof txSource)}
<TabsList> >
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger> <TabsList>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger> <TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
</TabsList> <TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</Tabs> </TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
title="Descargar todas las transacciones como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
</div>
{txQuery.isError ? ( {txQuery.isError ? (
<ErrorState <ErrorState
message="No se pudieron cargar las transacciones" message="No se pudieron cargar las transacciones"

View File

@@ -523,6 +523,7 @@ export default function Pensions() {
<button <button
key={key} key={key}
onClick={() => toggleFund(key)} onClick={() => toggleFund(key)}
aria-pressed={active}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ style={{
borderColor: fund.color, borderColor: fund.color,

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react'; import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api'; import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format'; import { formatAmount, formatDate } from '@/lib/format';
@@ -98,6 +98,15 @@ export default function Salarios() {
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p> <p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
</div> </div>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
title="Descargar salarios como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh"> <Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} /> <RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button> </Button>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
@@ -53,10 +54,6 @@ const METER_COLORS: Record<string, string> = {
'9345': '#f59e0b', '9345': '#f59e0b',
}; };
const MONTH_NAMES_ES = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const DEFAULT_METER_COLOR = '#8b5cf6'; const DEFAULT_METER_COLOR = '#8b5cf6';
@@ -67,7 +64,8 @@ const formatCRC = (amount: number): string =>
function periodLabel(period: string): string { function periodLabel(period: string): string {
const [yearStr, monthStr] = period.split('-'); const [yearStr, monthStr] = period.split('-');
const monthIdx = parseInt(monthStr, 10) - 1; // The shared month array is 1-indexed ('' at index 0).
const monthIdx = parseInt(monthStr, 10);
return `${MONTH_NAMES_ES[monthIdx]} ${yearStr.slice(2)}`; return `${MONTH_NAMES_ES[monthIdx]} ${yearStr.slice(2)}`;
} }

View File

@@ -0,0 +1,118 @@
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
HelpCircle,
RefreshCw,
} from 'lucide-react';
import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton';
function StatusIcon({ status }: { status: SyncSource['status'] }) {
if (status === 'ok')
return <CheckCircle2 className="w-5 h-5 text-emerald-500" aria-hidden="true" />;
if (status === 'warning')
return <AlertTriangle className="w-5 h-5 text-amber-500" aria-hidden="true" />;
return <HelpCircle className="w-5 h-5 text-muted-foreground" aria-hidden="true" />;
}
function statusLabel(s: SyncSource): string {
if (s.status === 'never') return 'Nunca recibido';
if (s.status === 'warning')
return `Sin datos nuevos (esperado cada ~${s.warn_after_days} días)`;
return 'Al día';
}
export default function SyncStatus() {
const query = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RefreshCw className="w-6 h-6 text-primary" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold tracking-tight">Sincronización</h1>
<p className="text-sm text-muted-foreground">
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw
className={query.isFetching ? 'w-4 h-4 mr-2 animate-spin' : 'w-4 h-4 mr-2'}
aria-hidden="true"
/>
Actualizar
</Button>
</div>
{query.isError ? (
<ErrorState
message="No se pudo consultar el estado de sincronización"
onRetry={() => query.refetch()}
/>
) : query.isPending ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : (
<div className="space-y-3">
{query.data.sources.map((s) => (
<Card
key={s.key}
className={
s.status !== 'ok' ? 'border-amber-500/40' : undefined
}
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{s.label}</span>
{s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{s.description}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground">
{s.last_received
? `Último: ${formatRelativeAge(s.last_received)}`
: '—'}
</p>
</div>
</CardContent>
</Card>
))}
<p className="text-xs text-muted-foreground">
Si una fuente aparece atrasada, revisá el flujo correspondiente en
n8n un cambio de formato en los correos del banco es la causa más
común.
</p>
</div>
)}
</div>
);
}