Enhance Telegram bot UI with YTD comparison, 12-month evolution, and improved navigation

- Add YTD year-over-year comparison table for cash flow evolution
- Extend monthly evolution from 6 to 12 months with dynamic year extraction
- Simplify monthly view to show only Net values aligned with YTD table
- Upgrade client/supplier display from Top 5 to Top 10 with alphabetical sorting
- Remove Refresh and Export buttons from dashboard and evolution views
- Add get_trends() API method for 12-month historical data from backend
- Fix default years to 2025/2024 for accurate YTD calculations

Changes:
- client.py: New get_trends() method calls /api/dashboard/trends endpoint
- helpers.py: Rewrite get_cashflow_evolution_data() to use trends and calculate YTD
- formatters.py: Complete redesign with YTD table and simplified 12-month Net view
- menus.py: Alphabetical sorting for clients/suppliers, removed refresh buttons
- handlers.py: Disabled refresh/export buttons on dashboard and evolution views

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-07 02:30:28 +02:00
parent a4ee394091
commit 87bd04e3ff
5 changed files with 244 additions and 78 deletions

View File

@@ -407,6 +407,38 @@ class BackendAPIClient:
logger.error(f"Failed to get monthly flows for company {company_id}: {e}") logger.error(f"Failed to get monthly flows for company {company_id}: {e}")
return None return None
async def get_trends(
self,
company_id: int,
jwt_token: str,
period: str = "12m"
) -> Optional[Dict[str, Any]]:
"""
Get trends data (12-month historical data for collections/payments).
Args:
company_id: Company ID
jwt_token: JWT access token
period: Period for trends (e.g., "12m", "6m", "ytd")
Returns:
Dict with trends data including periods, clienti_incasat, furnizori_achitat arrays
"""
try:
if not self.client:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.get(
f"/api/dashboard/trends?company={company_id}&period={period}",
headers=self._get_auth_headers(jwt_token)
)
return await self._handle_response(response)
except Exception as e:
logger.error(f"Failed to get trends for company {company_id}: {e}")
return None
# ========================================================================= # =========================================================================
# INVOICES ENDPOINTS # INVOICES ENDPOINTS
# ========================================================================= # =========================================================================

View File

@@ -203,9 +203,9 @@ def format_clients_balance_response(
text += f" - In termen: {in_term:,} RON\n" text += f" - In termen: {in_term:,} RON\n"
text += f" - Restanta: {overdue:,} RON\n\n" text += f" - Restanta: {overdue:,} RON\n\n"
# Top clients # Top 10 clients
if clients: if clients:
text += f"**Top Clienti** ({len(clients)} total):\n" text += f"**Top 10 Clienti** ({len(clients)} total):\n"
# Sort by balance descending # Sort by balance descending
sorted_clients = sorted( sorted_clients = sorted(
clients, clients,
@@ -213,12 +213,12 @@ def format_clients_balance_response(
reverse=True reverse=True
) )
for idx, client in enumerate(sorted_clients[:5], 1): for idx, client in enumerate(sorted_clients[:10], 1):
name = client.get('name', 'N/A') name = client.get('name', 'N/A')
balance = round(client.get('balance', 0)) balance = round(client.get('balance', 0))
text += f"{idx}. {name}: {balance:,} RON\n" text += f"{idx}. {name}: {balance:,} RON\n"
if len(clients) > 5: if len(clients) > 10:
text += f"\nApasa butonul pentru lista completa" text += f"\nApasa butonul pentru lista completa"
else: else:
text += "Nu exista clienti cu solduri." text += "Nu exista clienti cu solduri."
@@ -260,9 +260,9 @@ def format_suppliers_balance_response(
text += f" - In termen: {in_term:,} RON\n" text += f" - In termen: {in_term:,} RON\n"
text += f" - Restanta: {overdue:,} RON\n\n" text += f" - Restanta: {overdue:,} RON\n\n"
# Top suppliers # Top 10 suppliers
if suppliers: if suppliers:
text += f"**Top Furnizori** ({len(suppliers)} total):\n" text += f"**Top 10 Furnizori** ({len(suppliers)} total):\n"
# Sort by balance descending # Sort by balance descending
sorted_suppliers = sorted( sorted_suppliers = sorted(
suppliers, suppliers,
@@ -270,12 +270,12 @@ def format_suppliers_balance_response(
reverse=True reverse=True
) )
for idx, supplier in enumerate(sorted_suppliers[:5], 1): for idx, supplier in enumerate(sorted_suppliers[:10], 1):
name = supplier.get('name', 'N/A') name = supplier.get('name', 'N/A')
balance = round(supplier.get('balance', 0)) balance = round(supplier.get('balance', 0))
text += f"{idx}. {name}: {balance:,} RON\n" text += f"{idx}. {name}: {balance:,} RON\n"
if len(suppliers) > 5: if len(suppliers) > 10:
text += f"\nApasa butonul pentru lista completa" text += f"\nApasa butonul pentru lista completa"
else: else:
text += "Nu exista furnizori cu solduri." text += "Nu exista furnizori cu solduri."
@@ -289,56 +289,117 @@ def format_cashflow_evolution_response(
company_name: str = None company_name: str = None
) -> str: ) -> str:
""" """
Format cash flow evolution data (content only, no header). Format cash flow evolution data - Table format with mini-charts.
Args: Args:
performance_data: Dict with incasari_total, plati_total, net performance_data: Dict with current_year and previous_year YTD data
monthly_data: Dict with months, incasari, plati arrays monthly_data: Dict with months, incasari, plati arrays + prev year data
company_name: Company name (kept for compatibility, not used) company_name: Company name (kept for compatibility, not used)
Returns: Returns:
Formatted Markdown string for Telegram Formatted Markdown string for Telegram (monospace table)
Example: Example:
performance = {'incasari_total': 100000, 'plati_total': 80000, 'net': 20000} YTD 2024 vs 2023:
monthly = {'months': ['Ian', 'Feb'], 'incasari': [50000, 50000], 'plati': [40000, 40000]} 2024 2023 Δ Trend
text = format_cashflow_evolution_response(performance, monthly) Inc: 500,000 480,000 +4.2% ████░
Plt: 450,000 440,000 +2.3% ███░
Net: 50,000 40,000 +25.0% █████
""" """
text = "" text = ""
# Performance summary - rotunjit la leu (0 zecimale) # Helper functions
incasari_total = round(performance_data.get('incasari_total', 0)) def calc_percent_change(current: float, previous: float) -> str:
plati_total = round(performance_data.get('plati_total', 0)) """Calculate percentage change: +4.2% or -3.5%"""
net = round(performance_data.get('net', 0)) if previous == 0:
return "+100%" if current > 0 else "0.0%"
change = ((current - previous) / previous) * 100
sign = "+" if change >= 0 else ""
return f"{sign}{change:.1f}%"
text += "**Rezumat:**\n" def create_mini_chart(current: float, previous: float, width: int = 5) -> str:
text += f" - Total Incasari: {incasari_total:,} RON\n" """Create mini bar chart: ████░ (proportional bars)"""
text += f" - Total Plati: {plati_total:,} RON\n" if current == 0 and previous == 0:
text += f" - Net Cash Flow: {net:,} RON\n\n" return "" * width
# Monthly breakdown max_val = max(current, previous)
if max_val == 0:
return "" * width
curr_bars = int((current / max_val) * width)
prev_bars = int((previous / max_val) * width)
# Use filled and light blocks
filled = "" * curr_bars
light = "" * (width - curr_bars)
return filled + light
def get_trend_arrow(current: float, previous: float) -> str:
"""Get trend arrow: ↑ or ↓ or →"""
if current > previous * 1.02: # More than 2% increase
return ""
elif current < previous * 0.98: # More than 2% decrease
return ""
else:
return ""
# Extract YTD data
current = performance_data.get('current_year', {})
previous = performance_data.get('previous_year', {})
current_year = current.get('year', '2024')
previous_year = previous.get('year', '2023')
inc_cur = round(current.get('incasari', 0))
plt_cur = round(current.get('plati', 0))
net_cur = round(current.get('net', 0))
inc_prev = round(previous.get('incasari', 0))
plt_prev = round(previous.get('plati', 0))
net_prev = round(previous.get('net', 0))
# YTD Table Header
text += f"**YTD {current_year} vs {previous_year}:**\n"
text += f"` {current_year:>10} {previous_year:>10} Δ `\n"
# YTD Rows
inc_pct = calc_percent_change(inc_cur, inc_prev)
text += f"`Inc: {inc_cur:>10,} {inc_prev:>10,} {inc_pct:>6}`\n"
plt_pct = calc_percent_change(plt_cur, plt_prev)
text += f"`Plt: {plt_cur:>10,} {plt_prev:>10,} {plt_pct:>6}`\n"
net_pct = calc_percent_change(net_cur, net_prev)
text += f"`Net: {net_cur:>10,} {net_prev:>10,} {net_pct:>6}`\n\n"
# Monthly Evolution Table - Simplified (Net only)
months = monthly_data.get('months', []) months = monthly_data.get('months', [])
incasari = monthly_data.get('incasari', []) incasari = monthly_data.get('incasari', [])
plati = monthly_data.get('plati', []) plati = monthly_data.get('plati', [])
incasari_prev = monthly_data.get('incasari_prev', [])
plati_prev = monthly_data.get('plati_prev', [])
if months and len(months) > 0: if months and len(months) > 0:
text += "**Evolutie Lunara** (ultimele luni):\n" text += "**Evolutie Net (12 luni):**\n"
text += f"` {current_year:>10} {previous_year:>10} Δ `\n"
# Show last 6 months for i, month in enumerate(months):
display_count = min(6, len(months)) inc = incasari[i] if i < len(incasari) else 0
for i in range(display_count): plt = plati[i] if i < len(plati) else 0
month = months[-(display_count - i)] inc_p = incasari_prev[i] if i < len(incasari_prev) else 0
inc = round(incasari[-(display_count - i)]) if i < len(incasari) else 0 plt_p = plati_prev[i] if i < len(plati_prev) else 0
plt = round(plati[-(display_count - i)]) if i < len(plati) else 0
net_month = inc - plt
# Simple ASCII bar net = inc - plt
net_indicator = "+" if net_month > 0 else "-" if net_month < 0 else "=" net_p = inc_p - plt_p
text += f"\n**{month}:**\n" # Extract short month name (first 3 chars before apostrophe)
text += f" {net_indicator} Incasari: {inc:,} RON\n" month_short = month.split("'")[0][:3] if "'" in month else month[:3]
text += f" {net_indicator} Plati: {plt:,} RON\n"
text += f" {net_indicator} Net: {net_month:,} RON" # Calculate percentage change
net_pct = calc_percent_change(net, net_p)
# Format row: Luna Net'current Net'prev Δ (aligned with YTD)
text += f"`{month_short:<4} {int(net):>10,} {int(net_p):>10,} {net_pct:>6}`\n"
else: else:
text += "Nu exista date lunare disponibile." text += "Nu exista date lunare disponibile."

View File

@@ -984,7 +984,7 @@ async def evolutie_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
evolution_data['monthly'] evolution_data['monthly']
) )
response = format_response_with_company(content, company['name']) response = format_response_with_company(content, company['name'])
keyboard = create_action_buttons("evolutie", show_export=False) keyboard = create_action_buttons("evolutie", show_export=False, show_refresh=False)
await update.message.reply_text( await update.message.reply_text(
response, response,
@@ -1301,7 +1301,7 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str)
evolution_data['monthly'] evolution_data['monthly']
) )
response = format_response_with_company(content, company['name']) response = format_response_with_company(content, company['name'])
keyboard = create_action_buttons("evolutie", show_export=False) keyboard = create_action_buttons("evolutie", show_export=False, show_refresh=False)
await query.edit_message_text( await query.edit_message_text(
response, response,
@@ -2149,7 +2149,7 @@ async def _handle_sold_view(
content = format_dashboard_response(data) content = format_dashboard_response(data)
response = format_response_with_company(content, company['name']) response = format_response_with_company(content, company['name'])
keyboard = create_action_buttons("sold", show_export=True) keyboard = create_action_buttons("sold", show_export=False, show_refresh=False)
if is_callback: if is_callback:
await query_or_update.edit_message_text( await query_or_update.edit_message_text(

View File

@@ -524,61 +524,130 @@ async def get_cashflow_evolution_data(
period: str = "12m" period: str = "12m"
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Get cash flow evolution data. Get cash flow evolution data with YTD comparison.
Uses monthly flows endpoint which returns current month data. Uses trends endpoint which returns 12-month historical data for current and previous year.
Backend returns: {'inflows': float, 'outflows': float, 'period': str, 'currency': str} Calculates YTD for comparison and extracts last 12 months in reverse chronological order.
Args: Args:
company_id: Company ID company_id: Company ID
jwt_token: JWT authentication token jwt_token: JWT authentication token
period: Period for monthly data (default: "12m") period: Period for trends data (default: "12m")
Returns: Returns:
Dict with: Dict with:
- 'performance': Dict with incasari_total, plati_total, net - 'performance': Dict with YTD data for current and previous year
- 'monthly': Dict with months, incasari, plati arrays - 'monthly': Dict with last 12 months data (reverse chronological) + prev year comparison
None if request fails None if request fails
Example: Example:
data = await get_cashflow_evolution_data(1, token) data = await get_cashflow_evolution_data(1, token)
net = data['performance']['net'] # Net cash flow ytd_2025 = data['performance']['current_year']
months = data['monthly']['months'] # List of month names ytd_2024 = data['performance']['previous_year']
""" """
try: try:
client = get_backend_client() client = get_backend_client()
async with client: async with client:
# Get monthly flows (current month only from backend) # Get trends data (12 months of historical data)
monthly_flows = await client.get_monthly_flows( trends_data = await client.get_trends(
company_id=company_id, company_id=company_id,
jwt_token=jwt_token, jwt_token=jwt_token,
months=12 # Note: backend ignores this and returns only current month period="12m"
) )
if not monthly_flows: if not trends_data:
return None return None
# Transform backend response to expected format # Extract current year data
inflows = float(monthly_flows.get('inflows', 0)) periods = trends_data.get('periods', []) # ["2024-01", "2024-02", ...]
outflows = float(monthly_flows.get('outflows', 0)) clienti_incasat = trends_data.get('clienti_incasat', [])
period_name = monthly_flows.get('period', 'Luna curentă') furnizori_achitat = trends_data.get('furnizori_achitat', [])
# Calculate net # Extract previous year data
net = inflows - outflows previous_periods = trends_data.get('previous_periods', [])
clienti_incasat_prev = trends_data.get('clienti_incasat_prev', [])
furnizori_achitat_prev = trends_data.get('furnizori_achitat_prev', [])
# Build performance summary if not periods or not clienti_incasat or not furnizori_achitat:
performance = { logger.warning("Trends data missing required fields")
'incasari_total': inflows, return None
'plati_total': outflows,
'net': net # Calculate YTD (Year-To-Date) = sum of all available months
incasari_ytd = sum(clienti_incasat)
plati_ytd = sum(furnizori_achitat)
net_ytd = incasari_ytd - plati_ytd
incasari_ytd_prev = sum(clienti_incasat_prev) if clienti_incasat_prev else 0
plati_ytd_prev = sum(furnizori_achitat_prev) if furnizori_achitat_prev else 0
net_ytd_prev = incasari_ytd_prev - plati_ytd_prev
# Extract years from periods
current_year = periods[-1].split('-')[0] if periods else "2025"
previous_year = previous_periods[-1].split('-')[0] if previous_periods else "2024"
# Take last 12 months (current year)
last_12_periods = periods[-12:]
last_12_incasari = clienti_incasat[-12:]
last_12_plati = furnizori_achitat[-12:]
# Take corresponding previous year months
last_12_periods_prev = previous_periods[-12:] if previous_periods else []
last_12_incasari_prev = clienti_incasat_prev[-12:] if clienti_incasat_prev else [0] * 12
last_12_plati_prev = furnizori_achitat_prev[-12:] if furnizori_achitat_prev else [0] * 12
# Month abbreviations (Romanian)
month_abbr = {
'01': 'Ian', '02': 'Feb', '03': 'Mar', '04': 'Apr',
'05': 'Mai', '06': 'Iun', '07': 'Iul', '08': 'Aug',
'09': 'Sep', '10': 'Oct', '11': 'Noi', '12': 'Dec'
} }
# Build monthly breakdown (single month from backend) # Format months as "Noi'25/'24"
formatted_months = []
for i, period_str in enumerate(last_12_periods):
if '-' in period_str:
year = period_str.split('-')[0][-2:] # Last 2 digits: "25"
month_num = period_str.split('-')[1]
month_name = month_abbr.get(month_num, month_num)
# Get previous year month
prev_year = previous_year[-2:] if previous_year else "24"
formatted_months.append(f"{month_name}'{year}/'{prev_year}")
else:
formatted_months.append(period_str)
# Reverse chronological order (newest first)
formatted_months.reverse()
last_12_incasari.reverse()
last_12_plati.reverse()
last_12_incasari_prev.reverse()
last_12_plati_prev.reverse()
# Build performance summary (YTD)
performance = {
'current_year': {
'year': current_year,
'incasari': incasari_ytd,
'plati': plati_ytd,
'net': net_ytd
},
'previous_year': {
'year': previous_year,
'incasari': incasari_ytd_prev,
'plati': plati_ytd_prev,
'net': net_ytd_prev
}
}
# Build monthly breakdown (reverse chronological with prev year comparison)
monthly = { monthly = {
'months': [period_name], 'months': formatted_months,
'incasari': [inflows], 'incasari': last_12_incasari,
'plati': [outflows] 'plati': last_12_plati,
'incasari_prev': last_12_incasari_prev,
'plati_prev': last_12_plati_prev
} }
return { return {

View File

@@ -332,14 +332,17 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
""" """
keyboard = [] keyboard = []
# Sort clients alphabetically by name
sorted_clients = sorted(clients, key=lambda x: x.get('name', '').lower())
# Calculate pagination # Calculate pagination
total_clients = len(clients) total_clients = len(sorted_clients)
total_pages = (total_clients + max_items - 1) // max_items # Ceiling division total_pages = (total_clients + max_items - 1) // max_items # Ceiling division
start_idx = page * max_items start_idx = page * max_items
end_idx = min(start_idx + max_items, total_clients) end_idx = min(start_idx + max_items, total_clients)
# Display clients for current page # Display clients for current page
display_clients = clients[start_idx:end_idx] display_clients = sorted_clients[start_idx:end_idx]
# Add client buttons (1 per row) # Add client buttons (1 per row)
for client in display_clients: for client in display_clients:
@@ -385,10 +388,9 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
keyboard.append(nav_buttons) keyboard.append(nav_buttons)
# Navigation row: Back and Refresh (2 buttons per row) # Navigation row: Back button only
keyboard.append([ keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu"), InlineKeyboardButton("< Înapoi", callback_data="action:menu")
InlineKeyboardButton("Refresh", callback_data="action:refresh:clienti")
]) ])
return InlineKeyboardMarkup(keyboard) return InlineKeyboardMarkup(keyboard)
@@ -410,14 +412,17 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
""" """
keyboard = [] keyboard = []
# Sort suppliers alphabetically by name
sorted_suppliers = sorted(suppliers, key=lambda x: x.get('name', '').lower())
# Calculate pagination # Calculate pagination
total_suppliers = len(suppliers) total_suppliers = len(sorted_suppliers)
total_pages = (total_suppliers + max_items - 1) // max_items # Ceiling division total_pages = (total_suppliers + max_items - 1) // max_items # Ceiling division
start_idx = page * max_items start_idx = page * max_items
end_idx = min(start_idx + max_items, total_suppliers) end_idx = min(start_idx + max_items, total_suppliers)
# Display suppliers for current page # Display suppliers for current page
display_suppliers = suppliers[start_idx:end_idx] display_suppliers = sorted_suppliers[start_idx:end_idx]
# Add supplier buttons (1 per row) # Add supplier buttons (1 per row)
for supplier in display_suppliers: for supplier in display_suppliers:
@@ -463,10 +468,9 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
keyboard.append(nav_buttons) keyboard.append(nav_buttons)
# Navigation row: Back and Refresh (2 buttons per row) # Navigation row: Back button only
keyboard.append([ keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu"), InlineKeyboardButton("< Înapoi", callback_data="action:menu")
InlineKeyboardButton("Refresh", callback_data="action:refresh:furnizori")
]) ])
return InlineKeyboardMarkup(keyboard) return InlineKeyboardMarkup(keyboard)