diff --git a/app/modules/loyalty/routes/api/merchant.py b/app/modules/loyalty/routes/api/merchant.py index 5030d2c0..21669798 100644 --- a/app/modules/loyalty/routes/api/merchant.py +++ b/app/modules/loyalty/routes/api/merchant.py @@ -28,6 +28,7 @@ from app.modules.loyalty.schemas import ( ProgramResponse, ProgramUpdate, ) +from app.modules.loyalty.schemas.program import MerchantStatsResponse from app.modules.loyalty.services import program_service from app.modules.tenancy.models import Merchant @@ -49,6 +50,21 @@ def _build_program_response(program) -> ProgramResponse: return response +# ============================================================================= +# Statistics +# ============================================================================= + + +@router.get("/stats", response_model=MerchantStatsResponse) +def get_stats( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get merchant-wide loyalty statistics across all locations.""" + stats = program_service.get_merchant_stats(db, merchant.id) + return MerchantStatsResponse(**stats) + + # ============================================================================= # Program CRUD # ============================================================================= diff --git a/app/modules/loyalty/routes/pages/merchant.py b/app/modules/loyalty/routes/pages/merchant.py index 9737c3b0..25a59267 100644 --- a/app/modules/loyalty/routes/pages/merchant.py +++ b/app/modules/loyalty/routes/pages/merchant.py @@ -145,24 +145,13 @@ async def merchant_loyalty_analytics( """ Render merchant loyalty analytics page. - Shows aggregate loyalty program stats across all merchant stores. + Stats are loaded client-side via JS fetch. """ - merchant_id = merchant.id - stats = {} - try: - stats = program_service.get_merchant_stats(db, merchant_id) - except Exception: - logger.warning( - f"Failed to load loyalty stats for merchant {merchant_id}", - exc_info=True, - ) - context = _get_merchant_context( request, db, current_user, - loyalty_stats=stats, - merchant_id=merchant_id, + merchant_id=merchant.id, ) return templates.TemplateResponse( "loyalty/merchant/analytics.html", diff --git a/app/modules/loyalty/schemas/program.py b/app/modules/loyalty/schemas/program.py index 5f4d5224..e1466c26 100644 --- a/app/modules/loyalty/schemas/program.py +++ b/app/modules/loyalty/schemas/program.py @@ -284,11 +284,17 @@ class MerchantStatsResponse(BaseModel): total_points_issued: int = 0 total_points_redeemed: int = 0 + # Members + new_this_month: int = 0 + # Points - last 30 days points_issued_30d: int = 0 points_redeemed_30d: int = 0 transactions_30d: int = 0 + # Value + estimated_liability_cents: int = 0 + # Program info (optional) program: dict | None = None diff --git a/app/modules/loyalty/services/program_service.py b/app/modules/loyalty/services/program_service.py index 9ff29386..b08a48be 100644 --- a/app/modules/loyalty/services/program_service.py +++ b/app/modules/loyalty/services/program_service.py @@ -322,6 +322,52 @@ class ProgramService: db.query(func.count(func.distinct(LoyaltyProgram.merchant_id))).scalar() or 0 ) + # All-time points + total_points_issued = ( + db.query(func.sum(LoyaltyTransaction.points_delta)) + .filter(LoyaltyTransaction.points_delta > 0) + .scalar() + or 0 + ) + total_points_redeemed = ( + db.query(func.sum(func.abs(LoyaltyTransaction.points_delta))) + .filter(LoyaltyTransaction.points_delta < 0) + .scalar() + or 0 + ) + + # Outstanding points balance + total_points_balance = ( + db.query(func.sum(LoyaltyCard.points_balance)).scalar() or 0 + ) + + # New members this month + month_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + new_this_month = ( + db.query(func.count(LoyaltyCard.id)) + .filter(LoyaltyCard.created_at >= month_start) + .scalar() + or 0 + ) + + # Estimated liability (rough estimate: points / 100 as euros) + points_value_cents = total_points_balance // 100 * 100 + # Stamp liability across all programs + stamp_liability = 0 + programs = db.query(LoyaltyProgram).all() + for prog in programs: + stamp_value = prog.stamps_reward_value_cents or 0 + stamps_target = prog.stamps_target or 1 + current_stamps = ( + db.query(func.sum(LoyaltyCard.stamp_count)) + .filter(LoyaltyCard.program_id == prog.id) + .scalar() + or 0 + ) + stamp_liability += current_stamps * stamp_value // stamps_target + + estimated_liability_cents = stamp_liability + points_value_cents + return { "total_programs": total_programs, "active_programs": active_programs, @@ -331,6 +377,11 @@ class ProgramService: "transactions_30d": transactions_30d, "points_issued_30d": points_issued_30d, "points_redeemed_30d": points_redeemed_30d, + "total_points_issued": total_points_issued, + "total_points_redeemed": total_points_redeemed, + "total_points_balance": total_points_balance, + "new_this_month": new_this_month, + "estimated_liability_cents": estimated_liability_cents, } def check_self_enrollment_allowed(self, db: Session, merchant_id: int) -> None: @@ -843,6 +894,7 @@ class ProgramService: } thirty_days_ago = datetime.now(UTC) - timedelta(days=30) + month_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0) # Total cards stats["total_cards"] = ( @@ -920,6 +972,37 @@ class ProgramService: or 0 ) + # New members this month + stats["new_this_month"] = ( + db.query(func.count(LoyaltyCard.id)) + .filter( + LoyaltyCard.merchant_id == merchant_id, + LoyaltyCard.created_at >= month_start, + ) + .scalar() + or 0 + ) + + # Estimated liability (unredeemed value) + current_stamps = ( + db.query(func.sum(LoyaltyCard.stamp_count)) + .filter(LoyaltyCard.merchant_id == merchant_id) + .scalar() + or 0 + ) + stamp_value = program.stamps_reward_value_cents or 0 + stamps_target = program.stamps_target or 1 + current_points = ( + db.query(func.sum(LoyaltyCard.points_balance)) + .filter(LoyaltyCard.merchant_id == merchant_id) + .scalar() + or 0 + ) + points_value_cents = current_points // 100 * 100 + stats["estimated_liability_cents"] = ( + (current_stamps * stamp_value // stamps_target) + points_value_cents + ) + # Get all stores for this merchant for location breakdown stores = store_service.get_stores_by_merchant_id(db, merchant_id) diff --git a/app/modules/loyalty/static/admin/js/loyalty-analytics.js b/app/modules/loyalty/static/admin/js/loyalty-analytics.js index 9f402491..902e49d3 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-analytics.js +++ b/app/modules/loyalty/static/admin/js/loyalty-analytics.js @@ -1,21 +1,13 @@ // app/modules/loyalty/static/admin/js/loyalty-analytics.js // noqa: js-006 - async init pattern is safe, loadData has try/catch -// Use centralized logger const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.LogConfig.createLogger('loyaltyAnalytics'); -// ============================================ -// LOYALTY ANALYTICS FUNCTION -// ============================================ function adminLoyaltyAnalytics() { return { - // Inherit base layout functionality ...data(), - - // Page identifier for sidebar active state currentPage: 'loyalty-analytics', - // Stats stats: { total_programs: 0, active_programs: 0, @@ -24,59 +16,58 @@ function adminLoyaltyAnalytics() { transactions_30d: 0, points_issued_30d: 0, points_redeemed_30d: 0, - merchants_with_programs: 0 + merchants_with_programs: 0, + total_points_issued: 0, + total_points_redeemed: 0, + total_points_balance: 0, + new_this_month: 0, + estimated_liability_cents: 0, }, - // State + locations: [], + + // Merchant filter state + selectedMerchant: null, + merchantSearch: '', + merchantResults: [], + showMerchantDropdown: false, + searchingMerchants: false, + loading: false, error: null, - // Computed: Redemption rate percentage + _searchTimeout: null, + get redemptionRate() { if (this.stats.points_issued_30d === 0) return 0; return Math.round((this.stats.points_redeemed_30d / this.stats.points_issued_30d) * 100); }, - // Computed: Issued percentage for progress bar get issuedPercentage() { const total = this.stats.points_issued_30d + this.stats.points_redeemed_30d; if (total === 0) return 50; return Math.round((this.stats.points_issued_30d / total) * 100); }, - // Computed: Redeemed percentage for progress bar get redeemedPercentage() { return 100 - this.issuedPercentage; }, - // Initialize async init() { loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ==='); - - // Prevent multiple initializations - if (window._loyaltyAnalyticsInitialized) { - loyaltyAnalyticsLog.warn('Loyalty analytics page already initialized, skipping...'); - return; - } + if (window._loyaltyAnalyticsInitialized) return; window._loyaltyAnalyticsInitialized = true; - loyaltyAnalyticsLog.group('Loading analytics data'); await this.loadStats(); - loyaltyAnalyticsLog.groupEnd(); - loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ==='); }, - // Load platform stats async loadStats() { this.loading = true; this.error = null; try { - loyaltyAnalyticsLog.info('Fetching loyalty analytics...'); - const response = await apiClient.get('/admin/loyalty/stats'); - if (response) { this.stats = { total_programs: response.total_programs || 0, @@ -86,10 +77,15 @@ function adminLoyaltyAnalytics() { transactions_30d: response.transactions_30d || 0, points_issued_30d: response.points_issued_30d || 0, points_redeemed_30d: response.points_redeemed_30d || 0, - merchants_with_programs: response.merchants_with_programs || 0 + merchants_with_programs: response.merchants_with_programs || 0, + total_points_issued: response.total_points_issued || 0, + total_points_redeemed: response.total_points_redeemed || 0, + total_points_balance: response.total_points_balance || 0, + new_this_month: response.new_this_month || 0, + estimated_liability_cents: response.estimated_liability_cents || 0, }; - - loyaltyAnalyticsLog.info('Analytics loaded:', this.stats); + this.locations = []; + loyaltyAnalyticsLog.info('Platform stats loaded'); } } catch (error) { loyaltyAnalyticsLog.error('Failed to load analytics:', error); @@ -99,7 +95,77 @@ function adminLoyaltyAnalytics() { } }, - // Format number with thousands separator + searchMerchants() { + clearTimeout(this._searchTimeout); + this._searchTimeout = setTimeout(async () => { + if (this.merchantSearch.length < 2) { + this.merchantResults = []; + return; + } + this.searchingMerchants = true; + try { + const response = await apiClient.get(`/admin/loyalty/programs?search=${encodeURIComponent(this.merchantSearch)}&limit=10`); + if (response && response.programs) { + this.merchantResults = response.programs.map(p => ({ + id: p.merchant_id, + merchant_name: p.merchant_name || p.display_name || `Program #${p.id}`, + loyalty_type: p.loyalty_type, + })); + } + } catch (error) { + loyaltyAnalyticsLog.error('Merchant search failed:', error); + this.merchantResults = []; + } finally { + this.searchingMerchants = false; + } + }, 300); + }, + + async selectMerchant(item) { + this.selectedMerchant = item; + this.merchantSearch = ''; + this.merchantResults = []; + this.showMerchantDropdown = false; + + this.loading = true; + this.error = null; + + try { + const response = await apiClient.get(`/admin/loyalty/merchants/${item.id}/stats`); + if (response) { + this.stats = { + total_programs: 1, + active_programs: response.program?.is_active ? 1 : 0, + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + transactions_30d: response.transactions_30d || 0, + points_issued_30d: response.points_issued_30d || 0, + points_redeemed_30d: response.points_redeemed_30d || 0, + merchants_with_programs: 1, + total_points_issued: response.total_points_issued || 0, + total_points_redeemed: response.total_points_redeemed || 0, + total_points_balance: (response.total_points_issued || 0) - (response.total_points_redeemed || 0), + new_this_month: response.new_this_month || 0, + estimated_liability_cents: response.estimated_liability_cents || 0, + }; + this.locations = response.locations || []; + loyaltyAnalyticsLog.info('Merchant stats loaded for:', item.merchant_name); + } + } catch (error) { + loyaltyAnalyticsLog.error('Failed to load merchant stats:', error); + this.error = error.message || 'Failed to load merchant stats'; + } finally { + this.loading = false; + } + }, + + async clearMerchantFilter() { + this.selectedMerchant = null; + this.merchantSearch = ''; + this.merchantResults = []; + await this.loadStats(); + }, + formatNumber(num) { if (num === null || num === undefined) return '0'; return new Intl.NumberFormat('en-US').format(num); @@ -107,9 +173,7 @@ function adminLoyaltyAnalytics() { }; } -// Register logger for configuration if (!window.LogConfig.loggers.loyaltyAnalytics) { window.LogConfig.loggers.loyaltyAnalytics = window.LogConfig.createLogger('loyaltyAnalytics'); } - loyaltyAnalyticsLog.info('Loyalty analytics module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-analytics.js b/app/modules/loyalty/static/merchant/js/loyalty-analytics.js new file mode 100644 index 00000000..0071c970 --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-analytics.js @@ -0,0 +1,109 @@ +// app/modules/loyalty/static/merchant/js/loyalty-analytics.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.LogConfig.createLogger('loyaltyAnalytics'); + +function merchantLoyaltyAnalytics() { + return { + ...data(), + currentPage: 'loyalty-analytics', + + program: null, + locations: [], + + stats: { + total_cards: 0, + active_cards: 0, + new_this_month: 0, + total_points_issued: 0, + total_points_redeemed: 0, + total_points_balance: 0, + points_issued_30d: 0, + points_redeemed_30d: 0, + transactions_30d: 0, + avg_points_per_member: 0, + estimated_liability_cents: 0, + }, + + loading: false, + error: null, + + get redemptionRate() { + if (this.stats.points_issued_30d === 0) return 0; + return Math.round((this.stats.points_redeemed_30d / this.stats.points_issued_30d) * 100); + }, + + get issuedPercentage() { + const total = this.stats.points_issued_30d + this.stats.points_redeemed_30d; + if (total === 0) return 50; + return Math.round((this.stats.points_issued_30d / total) * 100); + }, + + get redeemedPercentage() { + return 100 - this.issuedPercentage; + }, + + async init() { + loyaltyAnalyticsLog.info('=== MERCHANT LOYALTY ANALYTICS PAGE INITIALIZING ==='); + if (window._loyaltyAnalyticsInitialized) return; + window._loyaltyAnalyticsInitialized = true; + + this.loadMenuConfig(); + await this.loadStats(); + loyaltyAnalyticsLog.info('=== MERCHANT LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadStats() { + this.loading = true; + this.error = null; + + try { + const response = await apiClient.get('/merchants/loyalty/stats'); + if (response) { + this.program = response.program || null; + this.locations = response.locations || []; + + const totalBalance = (response.total_points_issued || 0) - (response.total_points_redeemed || 0); + const avgPoints = response.active_cards > 0 + ? Math.round(totalBalance / response.active_cards * 100) / 100 + : 0; + + this.stats = { + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + new_this_month: response.new_this_month || 0, + total_points_issued: response.total_points_issued || 0, + total_points_redeemed: response.total_points_redeemed || 0, + total_points_balance: totalBalance, + points_issued_30d: response.points_issued_30d || 0, + points_redeemed_30d: response.points_redeemed_30d || 0, + transactions_30d: response.transactions_30d || 0, + avg_points_per_member: avgPoints, + estimated_liability_cents: response.estimated_liability_cents || 0, + }; + loyaltyAnalyticsLog.info('Merchant stats loaded'); + } + } catch (error) { + if (error.status === 404) { + loyaltyAnalyticsLog.info('No program found'); + this.program = null; + } else { + loyaltyAnalyticsLog.error('Failed to load stats:', error); + this.error = error.message || 'Failed to load analytics'; + } + } finally { + this.loading = false; + } + }, + + formatNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); + } + }; +} + +if (!window.LogConfig.loggers.loyaltyAnalytics) { + window.LogConfig.loggers.loyaltyAnalytics = window.LogConfig.createLogger('loyaltyAnalytics'); +} +loyaltyAnalyticsLog.info('Merchant loyalty analytics module loaded'); diff --git a/app/modules/loyalty/static/store/js/loyalty-analytics.js b/app/modules/loyalty/static/store/js/loyalty-analytics.js index 9fb94444..3cb0bf90 100644 --- a/app/modules/loyalty/static/store/js/loyalty-analytics.js +++ b/app/modules/loyalty/static/store/js/loyalty-analytics.js @@ -20,18 +20,34 @@ function storeLoyaltyAnalytics() { points_issued_30d: 0, points_redeemed_30d: 0, transactions_30d: 0, - avg_points_per_member: 0 + avg_points_per_member: 0, + estimated_liability_cents: 0, }, loading: false, error: null, + get redemptionRate() { + if (this.stats.points_issued_30d === 0) return 0; + return Math.round((this.stats.points_redeemed_30d / this.stats.points_issued_30d) * 100); + }, + + get issuedPercentage() { + const total = this.stats.points_issued_30d + this.stats.points_redeemed_30d; + if (total === 0) return 50; + return Math.round((this.stats.points_issued_30d / total) * 100); + }, + + get redeemedPercentage() { + return 100 - this.issuedPercentage; + }, + async init() { loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ==='); if (window._loyaltyAnalyticsInitialized) return; window._loyaltyAnalyticsInitialized = true; - // IMPORTANT: Call parent init first to set storeCode from URL + // Call parent init to set storeCode from URL const parentInit = data().init; if (parentInit) { await parentInit.call(this); @@ -70,7 +86,8 @@ function storeLoyaltyAnalytics() { points_issued_30d: response.points_issued_30d || 0, points_redeemed_30d: response.points_redeemed_30d || 0, transactions_30d: response.transactions_30d || 0, - avg_points_per_member: response.avg_points_per_member || 0 + avg_points_per_member: response.avg_points_per_member || 0, + estimated_liability_cents: response.estimated_liability_cents || 0, }; loyaltyAnalyticsLog.info('Stats loaded'); } @@ -83,7 +100,8 @@ function storeLoyaltyAnalytics() { }, formatNumber(num) { - return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); } }; } diff --git a/app/modules/loyalty/templates/loyalty/admin/analytics.html b/app/modules/loyalty/templates/loyalty/admin/analytics.html index a33daf23..bdf36270 100644 --- a/app/modules/loyalty/templates/loyalty/admin/analytics.html +++ b/app/modules/loyalty/templates/loyalty/admin/analytics.html @@ -1,14 +1,43 @@ {# app/modules/loyalty/templates/loyalty/admin/analytics.html #} {% extends "admin/base.html" %} -{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} {% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/inputs.html' import search_autocomplete, selected_item_display %} {% block title %}Loyalty Analytics{% endblock %} {% block alpine_data %}adminLoyaltyAnalytics(){% endblock %} {% block content %} -{{ page_header('Loyalty Analytics') }} +{% call page_header_flex(title='Loyalty Analytics', subtitle='Platform-wide loyalty program statistics') %} +
- Total Programs -
-- 0 -
-- active -
-- Total Members -
-- 0 -
-- active -
-- Points Issued (30d) -
-- 0 -
-- Points Redeemed (30d) -
-- 0 -
-Loyalty program statistics across all your stores.
-- Set up a loyalty program to see analytics here. -
+{{ loading_state('Loading analytics...') }} + +{{ error_state('Error loading analytics') }} + + +Set up a loyalty program to see analytics here.
+ class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700"> Create ProgramTotal Cards
- -Active Cards
- -Points Issued (30d)
- -Transactions (30d)
- -Total Points Issued
- -Total Points Redeemed
- -Points Redeemed (30d)
- -Outstanding Liability
- -Total Programs
+0
++ active +
+Total Members
+0
++ active +
++ {% if show_programs_card %}Total Members{% else %}Active Members{% endif %} +
+0
+Points Issued (30d)
+0
+Transactions (30d)
+0
+Total Points Issued
+0
+Total Points Redeemed
+0
+Points Redeemed (30d)
+0
+Outstanding Liability
+0
+| Store | +Enrolled | +Points Earned | +Points Redeemed | +Transactions (30d) | +
|---|---|---|---|---|
| + | + | + | + | + |
Total Members
-0
-Points Issued (30d)
-0
-Points Redeemed (30d)
-0
-Transactions (30d)
-0
-