mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +02:00
Compare commits
6 Commits
cdfa4ad90a
...
5612f69f11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5612f69f11 | ||
|
|
b3a7f5185a | ||
|
|
629d1181ae | ||
|
|
571428f5ac | ||
|
|
4ceb67564a | ||
|
|
8b7f3dff40 |
20
backend/app/api/v1/endpoints/sync_status.py
Normal file
20
backend/app/api/v1/endpoints/sync_status.py
Normal 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")),
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
57
backend/app/services/csv_export.py
Normal file
57
backend/app/services/csv_export.py
Normal 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()
|
||||||
94
backend/app/services/sync_status.py
Normal file
94
backend/app/services/sync_status.py
Normal 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
|
||||||
112
backend/tests/test_sync_and_export.py
Normal file
112
backend/tests/test_sync_and_export.py
Normal 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"
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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' ? '-' : '+'}
|
||||||
|
|||||||
@@ -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
28
frontend/src/lib/dates.ts
Normal 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`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
frontend/src/pages/SyncStatus.tsx
Normal file
118
frontend/src/pages/SyncStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user