feat: enhance homepage with financial summary and sparklines

- Add dashboard API endpoints for financial summary, recent revenus, monthly trends, and immeuble shortcuts
- Create new dashboard components: FinancialSummary with sparklines, QuickActions, RecentRevenus, MiniTrendChart, ImmeubleShortcuts
- Refactor HomePage with prominent drag & drop zone and data-driven cards
- Financial cards now show last document data with 6-month trend sparklines in background
- Remove redundant import button, keep single upload zone at top
This commit is contained in:
2026-01-20 04:53:04 +01:00
parent cae8d5b963
commit 281f69f39e
9 changed files with 1275 additions and 70 deletions

View File

@@ -8,7 +8,13 @@ from fastapi.staticfiles import StaticFiles
from .. import __version__
from ..database import init_db
from .routes import extraction_router, documents_router, tags_router, analytics_router
from .routes import (
extraction_router,
documents_router,
tags_router,
analytics_router,
dashboard_router,
)
app = FastAPI(
title="Plesna Gérance API",
@@ -32,6 +38,7 @@ app.include_router(extraction_router)
app.include_router(documents_router)
app.include_router(tags_router)
app.include_router(analytics_router)
app.include_router(dashboard_router)
# Health check endpoints (keep in main app)

View File

@@ -4,10 +4,12 @@ from .extraction import router as extraction_router
from .documents import router as documents_router
from .tags import router as tags_router
from .analytics import router as analytics_router
from .dashboard import router as dashboard_router
__all__ = [
"extraction_router",
"documents_router",
"tags_router",
"analytics_router",
"dashboard_router",
]

View File

@@ -0,0 +1,425 @@
"""Dashboard routes - Aggregated data for the home page."""
from datetime import date, timedelta
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import select, func, desc
from pydantic import BaseModel
from ...database import get_session
from ...database.models import (
Document,
Immeuble,
Lot,
Locataire,
Revenu,
Depense,
)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
# ============================================================
# Response models
# ============================================================
class MonthlyDataPoint(BaseModel):
"""Point de donnees mensuel pour sparkline."""
month: str
value: float
class FinancialSummaryResponse(BaseModel):
"""Resume financier du dernier document avec historique pour sparklines."""
# Valeurs du dernier document
last_document_date: str | None = None
last_document_reference: str | None = None
revenus: float
impayes: float
depenses: float
solde: float
# Historique pour sparklines (6 derniers mois)
revenus_history: list[MonthlyDataPoint] = []
impayes_history: list[MonthlyDataPoint] = []
depenses_history: list[MonthlyDataPoint] = []
solde_history: list[MonthlyDataPoint] = []
class RecentRevenuResponse(BaseModel):
"""Revenu recent avec details."""
id: int
document_date: str
locataire_nom: str
lot_numero: str
immeuble_code: str
total: float
impayes: float
type_ligne: str
class MonthlyTrendResponse(BaseModel):
"""Tendance mensuelle pour graphique."""
month: str # "2024-01"
revenus: float
depenses: float
solde: float
class ImmeubleShortcutResponse(BaseModel):
"""Raccourci immeuble pour acces rapide."""
id: int
code: str
adresse: str | None
ville: str | None
nb_lots: int
nb_locataires: int
total_revenus: float
total_impayes: float
class DashboardStatsResponse(BaseModel):
"""Stats enrichies pour le dashboard."""
documents: int
immeubles: int
lots: int
locataires: int
total_revenus: float
total_depenses: float
total_impayes: float
# ============================================================
# Endpoints
# ============================================================
@router.get("/stats", response_model=DashboardStatsResponse)
async def get_dashboard_stats(
session: Session = Depends(get_session),
) -> DashboardStatsResponse:
"""Retourne les statistiques enrichies pour le dashboard.
Inclut les compteurs et les totaux financiers.
"""
# Compteurs
documents_count = session.execute(select(func.count(Document.id))).scalar() or 0
immeubles_count = session.execute(select(func.count(Immeuble.id))).scalar() or 0
lots_count = session.execute(select(func.count(Lot.id))).scalar() or 0
locataires_count = session.execute(select(func.count(Locataire.id))).scalar() or 0
# Totaux financiers
total_revenus = session.execute(select(func.sum(Revenu.total))).scalar() or 0.0
total_depenses = session.execute(select(func.sum(Depense.debit))).scalar() or 0.0
total_impayes = session.execute(select(func.sum(Revenu.impayes))).scalar() or 0.0
return DashboardStatsResponse(
documents=documents_count,
immeubles=immeubles_count,
lots=lots_count,
locataires=locataires_count,
total_revenus=total_revenus,
total_depenses=total_depenses,
total_impayes=total_impayes,
)
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
async def get_financial_summary(
session: Session = Depends(get_session),
) -> FinancialSummaryResponse:
"""Retourne le resume financier du dernier document avec historique pour sparklines.
- Valeurs principales basees sur le dernier document importe
- Historique sur 6 mois pour les sparklines
"""
# Recuperer le dernier document
last_doc_stmt = select(Document).order_by(desc(Document.date)).limit(1)
last_doc = session.execute(last_doc_stmt).scalar()
# Valeurs du dernier document
revenus = 0.0
impayes = 0.0
depenses = 0.0
last_document_date = None
last_document_reference = None
if last_doc:
last_document_date = str(last_doc.date)
last_document_reference = last_doc.reference
# Revenus du dernier document
revenus = (
session.execute(
select(func.sum(Revenu.total)).where(Revenu.document_id == last_doc.id)
).scalar()
or 0.0
)
# Impayes du dernier document
impayes = (
session.execute(
select(func.sum(Revenu.impayes)).where(
Revenu.document_id == last_doc.id
)
).scalar()
or 0.0
)
# Depenses du dernier document
depenses = (
session.execute(
select(func.sum(Depense.debit)).where(
Depense.document_id == last_doc.id
)
).scalar()
or 0.0
)
solde = revenus - depenses
# Historique sur 6 mois pour sparklines
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=6 * 31)).replace(day=1)
# Revenus par mois
revenus_by_month: dict[str, float] = defaultdict(float)
impayes_by_month: dict[str, float] = defaultdict(float)
depenses_by_month: dict[str, float] = defaultdict(float)
# Recuperer revenus et impayes par mois
revenus_stmt = (
select(
Document.date,
func.sum(Revenu.total).label("total"),
func.sum(Revenu.impayes).label("impayes"),
)
.join(Revenu, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
)
for row in session.execute(revenus_stmt):
month_key = row.date.strftime("%Y-%m")
revenus_by_month[month_key] += row.total or 0.0
impayes_by_month[month_key] += row.impayes or 0.0
# Recuperer depenses par mois
depenses_stmt = (
select(Document.date, func.sum(Depense.debit).label("total"))
.join(Depense, Depense.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
)
for row in session.execute(depenses_stmt):
month_key = row.date.strftime("%Y-%m")
depenses_by_month[month_key] += row.total or 0.0
# Generer les 6 derniers mois
all_months = []
current = today.replace(day=1)
for _ in range(6):
all_months.insert(0, current.strftime("%Y-%m"))
current = (current - timedelta(days=1)).replace(day=1)
# Construire les listes d'historique
revenus_history = [
MonthlyDataPoint(month=m, value=revenus_by_month.get(m, 0.0))
for m in all_months
]
impayes_history = [
MonthlyDataPoint(month=m, value=impayes_by_month.get(m, 0.0))
for m in all_months
]
depenses_history = [
MonthlyDataPoint(month=m, value=depenses_by_month.get(m, 0.0))
for m in all_months
]
solde_history = [
MonthlyDataPoint(
month=m,
value=revenus_by_month.get(m, 0.0) - depenses_by_month.get(m, 0.0),
)
for m in all_months
]
return FinancialSummaryResponse(
last_document_date=last_document_date,
last_document_reference=last_document_reference,
revenus=revenus,
impayes=impayes,
depenses=depenses,
solde=solde,
revenus_history=revenus_history,
impayes_history=impayes_history,
depenses_history=depenses_history,
solde_history=solde_history,
)
@router.get("/recent-revenus", response_model=list[RecentRevenuResponse])
async def get_recent_revenus(
limit: int = 10,
session: Session = Depends(get_session),
) -> list[RecentRevenuResponse]:
"""Retourne les derniers revenus/loyers enregistres.
- **limit**: Nombre maximum de resultats (defaut: 10)
"""
stmt = (
select(
Revenu,
Document.date.label("document_date"),
Locataire.nom.label("locataire_nom"),
Lot.numero.label("lot_numero"),
Immeuble.code.label("immeuble_code"),
)
.join(Document, Revenu.document_id == Document.id)
.join(Locataire, Revenu.locataire_id == Locataire.id)
.join(Lot, Revenu.lot_id == Lot.id)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.order_by(desc(Document.date), desc(Revenu.id))
.limit(limit)
)
result = session.execute(stmt)
rows = result.all()
return [
RecentRevenuResponse(
id=row.Revenu.id,
document_date=str(row.document_date),
locataire_nom=row.locataire_nom,
lot_numero=row.lot_numero,
immeuble_code=row.immeuble_code,
total=row.Revenu.total or 0.0,
impayes=row.Revenu.impayes or 0.0,
type_ligne=row.Revenu.type_ligne or "",
)
for row in rows
]
@router.get("/monthly-trends", response_model=list[MonthlyTrendResponse])
async def get_monthly_trends(
months: int = 6,
session: Session = Depends(get_session),
) -> list[MonthlyTrendResponse]:
"""Retourne les tendances mensuelles pour le graphique.
- **months**: Nombre de mois a inclure (defaut: 6)
"""
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
# Recuperer tous les revenus depuis start_date
revenus_stmt = (
select(Document.date, func.sum(Revenu.total).label("total"))
.join(Revenu, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
)
revenus_result = session.execute(revenus_stmt)
revenus_by_month: dict[str, float] = defaultdict(float)
for row in revenus_result:
month_key = row.date.strftime("%Y-%m")
revenus_by_month[month_key] += row.total or 0.0
# Recuperer toutes les depenses depuis start_date
depenses_stmt = (
select(Document.date, func.sum(Depense.debit).label("total"))
.join(Depense, Depense.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
)
depenses_result = session.execute(depenses_stmt)
depenses_by_month: dict[str, float] = defaultdict(float)
for row in depenses_result:
month_key = row.date.strftime("%Y-%m")
depenses_by_month[month_key] += row.total or 0.0
# Combiner et trier
all_months = sorted(set(revenus_by_month.keys()) | set(depenses_by_month.keys()))
# Limiter aux derniers mois demandes
all_months = all_months[-months:]
return [
MonthlyTrendResponse(
month=month,
revenus=revenus_by_month.get(month, 0.0),
depenses=depenses_by_month.get(month, 0.0),
solde=revenus_by_month.get(month, 0.0) - depenses_by_month.get(month, 0.0),
)
for month in all_months
]
@router.get("/immeubles-shortcuts", response_model=list[ImmeubleShortcutResponse])
async def get_immeubles_shortcuts(
limit: int = 5,
session: Session = Depends(get_session),
) -> list[ImmeubleShortcutResponse]:
"""Retourne les immeubles pour acces rapide avec stats.
Trie par nombre de documents (plus actifs en premier).
- **limit**: Nombre maximum d'immeubles (defaut: 5)
"""
# Requete pour les immeubles avec stats
stmt = (
select(
Immeuble,
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
func.count(func.distinct(Document.id)).label("nb_documents"),
)
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
.group_by(Immeuble.id)
.order_by(desc("nb_documents"))
.limit(limit)
)
result = session.execute(stmt)
immeubles = result.all()
# Pour chaque immeuble, recuperer les totaux revenus/impayes
shortcuts = []
for row in immeubles:
immeuble = row.Immeuble
# Revenus de cet immeuble
revenus_stmt = (
select(func.sum(Revenu.total), func.sum(Revenu.impayes))
.join(Lot, Revenu.lot_id == Lot.id)
.where(Lot.immeuble_id == immeuble.id)
)
rev_result = session.execute(revenus_stmt).first()
total_revenus = rev_result[0] or 0.0 if rev_result else 0.0
total_impayes = rev_result[1] or 0.0 if rev_result else 0.0
shortcuts.append(
ImmeubleShortcutResponse(
id=immeuble.id,
code=immeuble.code,
adresse=immeuble.adresse,
ville=immeuble.ville,
nb_lots=row.nb_lots or 0,
nb_locataires=row.nb_locataires or 0,
total_revenus=total_revenus,
total_impayes=total_impayes,
)
)
return shortcuts