diff --git a/backend/app/api/v1/endpoints/pensions.py b/backend/app/api/v1/endpoints/pensions.py index 785a0a8..2410981 100644 --- a/backend/app/api/v1/endpoints/pensions.py +++ b/backend/app/api/v1/endpoints/pensions.py @@ -1,3 +1,5 @@ +from datetime import date + from fastapi import APIRouter, Depends, UploadFile from pydantic import BaseModel from sqlmodel import Session, select @@ -12,11 +14,93 @@ router = APIRouter(prefix="/pensions", tags=["pensions"]) class PensionUploadResult(BaseModel): imported: int + updated: int duplicates: int errors: list[str] snapshots: list[PensionSnapshotRead] +class PensionManualEntry(BaseModel): + fund: str + period_start: date + period_end: date + saldo_anterior: float + aportes: float + rendimientos: float + retiros: float + traslados: float + comision: float + correccion: float = 0.0 + bonificacion: float = 0.0 + saldo_final: float + + +class PensionManualRequest(BaseModel): + entries: list[PensionManualEntry] + + +def _upsert_snapshot( + session: Session, + fund: str, + period_start: date, + period_end: date, + saldo_anterior: float, + aportes: float, + rendimientos: float, + retiros: float, + traslados: float, + comision: float, + correccion: float, + bonificacion: float, + saldo_final: float, + source_filename: str, + contract_number: str = "", +) -> tuple[PensionSnapshot, bool]: + """Insert or update a pension snapshot. Returns (row, is_new).""" + existing = session.exec( + select(PensionSnapshot).where( + PensionSnapshot.fund == Bank(fund), + PensionSnapshot.period_start == period_start, + PensionSnapshot.period_end == period_end, + ) + ).first() + + if existing: + existing.saldo_anterior = saldo_anterior + existing.aportes = aportes + existing.rendimientos = rendimientos + existing.retiros = retiros + existing.traslados = traslados + existing.comision = comision + existing.correccion = correccion + existing.bonificacion = bonificacion + existing.saldo_final = saldo_final + existing.source_filename = source_filename + if contract_number: + existing.contract_number = contract_number + session.add(existing) + return existing, False + + row = PensionSnapshot( + fund=Bank(fund), + contract_number=contract_number, + period_start=period_start, + period_end=period_end, + saldo_anterior=saldo_anterior, + aportes=aportes, + rendimientos=rendimientos, + retiros=retiros, + traslados=traslados, + comision=comision, + correccion=correccion, + bonificacion=bonificacion, + saldo_final=saldo_final, + source_filename=source_filename, + ) + session.add(row) + return row, True + + @router.post("/upload", response_model=PensionUploadResult) async def upload_pension_pdfs( files: list[UploadFile], @@ -24,9 +108,9 @@ async def upload_pension_pdfs( _user: str = Depends(get_current_user), ): imported = 0 - duplicates = 0 + updated = 0 errors: list[str] = [] - created: list[PensionSnapshot] = [] + results: list[PensionSnapshot] = [] for file in files: filename = file.filename or "unknown.pdf" @@ -41,20 +125,9 @@ async def upload_pension_pdfs( continue for snap in fund_snapshots: - existing = session.exec( - select(PensionSnapshot).where( - PensionSnapshot.fund == Bank(snap.fund), - PensionSnapshot.period_start == snap.period_start, - PensionSnapshot.period_end == snap.period_end, - ) - ).first() - if existing: - duplicates += 1 - continue - - row = PensionSnapshot( - fund=Bank(snap.fund), - contract_number=snap.contract_number, + row, is_new = _upsert_snapshot( + session, + fund=snap.fund, period_start=snap.period_start, period_end=snap.period_end, saldo_anterior=snap.saldo_anterior, @@ -67,21 +140,72 @@ async def upload_pension_pdfs( bonificacion=snap.bonificacion, saldo_final=snap.saldo_final, source_filename=filename, + contract_number=snap.contract_number, ) - session.add(row) - created.append(row) - imported += 1 + results.append(row) + if is_new: + imported += 1 + else: + updated += 1 - if imported > 0: + if imported > 0 or updated > 0: session.commit() - for row in created: + for row in results: session.refresh(row) return PensionUploadResult( imported=imported, - duplicates=duplicates, + updated=updated, + duplicates=0, errors=errors, - snapshots=[PensionSnapshotRead.model_validate(r) for r in created], + snapshots=[PensionSnapshotRead.model_validate(r) for r in results], + ) + + +@router.post("/manual", response_model=PensionUploadResult) +def submit_manual_entries( + body: PensionManualRequest, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + imported = 0 + updated = 0 + results: list[PensionSnapshot] = [] + + for entry in body.entries: + row, is_new = _upsert_snapshot( + session, + fund=entry.fund, + period_start=entry.period_start, + period_end=entry.period_end, + saldo_anterior=entry.saldo_anterior, + aportes=entry.aportes, + rendimientos=entry.rendimientos, + retiros=entry.retiros, + traslados=entry.traslados, + comision=entry.comision, + correccion=entry.correccion, + bonificacion=entry.bonificacion, + saldo_final=entry.saldo_final, + source_filename="manual-entry", + ) + results.append(row) + if is_new: + imported += 1 + else: + updated += 1 + + if imported > 0 or updated > 0: + session.commit() + for row in results: + session.refresh(row) + + return PensionUploadResult( + imported=imported, + updated=updated, + duplicates=0, + errors=[], + snapshots=[PensionSnapshotRead.model_validate(r) for r in results], ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 32d034a..e153ff4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -269,11 +269,27 @@ export interface PensionSnapshot { export interface PensionUploadResult { imported: number; + updated: number; duplicates: number; errors: string[]; snapshots: PensionSnapshot[]; } +export interface PensionManualEntry { + fund: string; + period_start: string; + period_end: string; + saldo_anterior: number; + aportes: number; + rendimientos: number; + retiros: number; + traslados: number; + comision: number; + correccion: number; + bonificacion: number; + saldo_final: number; +} + export const uploadPensionPDFs = (files: File[]) => { const form = new FormData(); files.forEach((f) => form.append('files', f)); @@ -285,3 +301,6 @@ export const getPensionSnapshots = () => export const getPensionFundSummary = () => api.get('/pensions/fund-summary'); + +export const submitPensionManualEntries = (entries: PensionManualEntry[]) => + api.post('/pensions/manual', { entries }); diff --git a/frontend/src/components/PensionManualEntryModal.tsx b/frontend/src/components/PensionManualEntryModal.tsx new file mode 100644 index 0000000..fee56f7 --- /dev/null +++ b/frontend/src/components/PensionManualEntryModal.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react'; +import { type PensionUploadResult, submitPensionManualEntries } from '../api'; +import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +interface Props { + onClose: () => void; + onImported: () => void; +} + +const formatCRC = (n: number) => + new Intl.NumberFormat('es-CR', { + style: 'currency', + currency: 'CRC', + maximumFractionDigits: 0, + }).format(n); + +const FUND_LABELS: Record = { + ROP: 'ROP', + FCL: 'FCL', + VOL: 'Voluntario', +}; + +export default function PensionManualEntryModal({ onClose, onImported }: Props) { + const [text, setText] = useState(''); + const [parsed, setParsed] = useState(null); + const [parseError, setParseError] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + + const handlePreview = () => { + setParseError(''); + const entries = parsePensionPaste(text); + if (entries.length === 0) { + setParseError('No se encontraron datos de fondos. Verifica que el texto pegado tenga el formato correcto.'); + setParsed(null); + return; + } + setParsed(entries); + }; + + const handleSubmit = async () => { + if (!parsed) return; + setSubmitting(true); + try { + const { data } = await submitPensionManualEntries(parsed); + setResult(data); + if (data.imported > 0 || data.updated > 0) onImported(); + } catch (err) { + console.error(err); + } finally { + setSubmitting(false); + } + }; + + return ( + { if (!open) onClose(); }}> + + + + + Ingresar Datos de Pensión + + + + {result ? ( +
+ + + Datos Guardados + + {result.imported > 0 && `${result.imported} nuevo(s)`} + {result.imported > 0 && result.updated > 0 && ' · '} + {result.updated > 0 && `${result.updated} actualizado(s)`} + + + +
+ ) : !parsed ? ( +
+
+ +