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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
425
src/plesna_gerance/api/routes/dashboard.py
Normal file
425
src/plesna_gerance/api/routes/dashboard.py
Normal 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
|
||||
Reference in New Issue
Block a user