diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..a85ddb0ec 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary +from app.services.reservations import calculate_monthly_revenue from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -8,15 +9,20 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[int] = None, + year: Optional[int] = None, current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - + tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" - - revenue_data = await get_revenue_summary(property_id, tenant_id) - + + if month is not None and year is not None: + revenue_data = await calculate_monthly_revenue(property_id, tenant_id, month, year) + else: + revenue_data = await get_revenue_summary(property_id, tenant_id) + total_revenue_float = float(revenue_data['total']) - + return { "property_id": revenue_data['property_id'], "total_revenue": total_revenue_float, diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..adf825d46 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,5 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings @@ -15,11 +14,12 @@ async def initialize(self): """Initialize database connection pool""" try: # Create async engine with connection pooling - database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}" + database_url = settings.database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) self.engine = create_async_engine( database_url, - poolclass=QueuePool, pool_size=20, # Number of connections to maintain max_overflow=30, # Additional connections when needed pool_pre_ping=True, # Validate connections @@ -45,7 +45,7 @@ async def close(self): if self.engine: await self.engine.dispose() - async def get_session(self) -> AsyncSession: + def get_session(self) -> AsyncSession: """Get database session from pool""" if not self.session_factory: raise Exception("Database pool not initialized") diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..672e3f9d6 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,7 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache cached = await redis_client.get(cache_key) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..8b73ef77a 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -2,34 +2,62 @@ from decimal import Decimal from typing import Dict, Any, List -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]: """ - Calculates revenue for a specific month. + Calculates revenue for a specific calendar month. + + Reservations are bucketed by month using the PROPERTY'S OWN local + timezone, not UTC. check_in_date is stored as timestamptz (UTC), so a + booking near midnight local time can fall on the other side of the + month boundary once converted - e.g. 2024-02-29 23:30 UTC is already + 2024-03-01 in Europe/Paris. Comparing against naive UTC month + boundaries would silently drop that reservation from March. """ + from app.core.database_pool import DatabasePool + from sqlalchemy import text start_date = datetime(year, month, 1) if month < 12: end_date = datetime(year, month + 1, 1) else: end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") - # SQL Simulation (This would be executed against the actual DB) - query = """ - SELECT SUM(total_amount) as total - FROM reservations - WHERE property_id = $1 - AND tenant_id = $2 - AND check_in_date >= $3 - AND check_in_date < $4 - """ - - # In production this query executes against a database session. - # result = await db.fetch_val(query, property_id, tenant_id, start_date, end_date) - # return result or Decimal('0') - - return Decimal('0') # Placeholder for now until DB connection is finalized + db_pool = DatabasePool() + await db_pool.initialize() + + if not db_pool.session_factory: + raise Exception("Database pool not available") + + async with db_pool.get_session() as session: + query = text(""" + SELECT SUM(r.total_amount) as total, COUNT(*) as cnt + FROM reservations r + JOIN properties p ON p.id = r.property_id AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id + AND r.tenant_id = :tenant_id + AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date + """) + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + }) + row = result.fetchone() + + total = Decimal(str(row.total)) if row and row.total is not None else Decimal('0.00') + count = row.cnt if row else 0 + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "month": month, + "year": year, + "total": str(total), + "currency": "USD", + "count": count, + } async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..06cb8bd4a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,7 @@ pytz redis>=5.0.0 requests sendgrid -sqlalchemy>=2.0.0 +sqlalchemy[asyncio]>=2.0.0 supabase tenacity>=8.0.0 uvicorn[standard] diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..b8081ee9a 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,36 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { useAppContext } from "../contexts/AppContext"; -const PROPERTIES = [ - { id: 'prop-001', name: 'Beach House Alpha' }, - { id: 'prop-002', name: 'City Apartment Downtown' }, - { id: 'prop-003', name: 'Country Villa Estate' }, - { id: 'prop-004', name: 'Lakeside Cottage' }, - { id: 'prop-005', name: 'Urban Loft Modern' } -]; +// Each property id is only unique WITHIN a tenant (see database/seed.sql, +// properties has a composite PK of (id, tenant_id)) - prop-001 means a +// different property depending on which tenant you are. +const PROPERTIES_BY_TENANT: Record = { + 'tenant-a': [ + { id: 'prop-001', name: 'Beach House Alpha' }, + { id: 'prop-002', name: 'City Apartment Downtown' }, + { id: 'prop-003', name: 'Country Villa Estate' }, + ], + 'tenant-b': [ + { id: 'prop-001', name: 'Mountain Lodge Beta' }, + { id: 'prop-004', name: 'Lakeside Cottage' }, + { id: 'prop-005', name: 'Urban Loft Modern' }, + ], +}; const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const { user } = useAppContext(); + const PROPERTIES = (user?.tenant_id && PROPERTIES_BY_TENANT[user.tenant_id]) || []; + const [selectedProperty, setSelectedProperty] = useState(''); + + // PROPERTIES depends on user.tenant_id, which loads asynchronously after + // mount - sync the selection once the tenant's property list becomes + // available instead of getting stuck on the empty initial value. + useEffect(() => { + if (!selectedProperty && PROPERTIES.length > 0) { + setSelectedProperty(PROPERTIES[0].id); + } + }, [PROPERTIES, selectedProperty]); return (