From fcc719201543756e1acd7171aed5aee194673a06 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 21 Apr 2026 18:59:30 +0200 Subject: [PATCH] feat: Add rental statistics and pricing tabs to hardware detail view --- app/emails/backend/router.py | 20 ++ app/modules/hardware/frontend/views.py | 101 ++++++++- app/modules/hardware/templates/detail.html | 229 +++++++++++++++++++++ app/modules/sag/templates/detail.html | 121 ++++++++--- app/settings/backend/views.py | 16 +- app/shared/frontend/base.html | 44 +++- static/js/bottom-bar.js | 8 + 7 files changed, 494 insertions(+), 45 deletions(-) diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 1fe4d51..1ff4cbf 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -1183,6 +1183,25 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques (sag_id, email_id) ) + attachments_linked = 0 + try: + # Reuse workflow helper so attachments become real sag_files entries. + attachments_linked = int(email_workflow_service._copy_email_attachments_to_case(email_id, sag_id, None) or 0) + if attachments_linked > 0: + logger.info( + "📎 Linked %s attachment(s) from email %s to SAG-%s during create-sag", + attachments_linked, + email_id, + sag_id, + ) + except Exception as attach_exc: + logger.warning( + "⚠️ Could not auto-link attachments from email %s to SAG-%s: %s", + email_id, + sag_id, + attach_exc, + ) + if payload.contact_id: execute_update( """ @@ -1201,6 +1220,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques "success": True, "email_id": email_id, "sag": sag, + "attachments_linked": attachments_linked, "message": "SAG oprettet fra e-mail" } diff --git a/app/modules/hardware/frontend/views.py b/app/modules/hardware/frontend/views.py index 84b592b..c541c45 100644 --- a/app/modules/hardware/frontend/views.py +++ b/app/modules/hardware/frontend/views.py @@ -567,6 +567,103 @@ async def hardware_detail(request: Request, hardware_id: int): """ all_locations_flat = execute_query(all_locations_query) location_tree = build_location_tree(all_locations_flat) + + # Rental statistics for this hardware asset + rental_overview_query = """ + SELECT + COUNT(*) AS total_bindings, + COALESCE(SUM(CASE WHEN b.status = 'active' AND b.deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS active_bindings, + MIN(b.start_date) AS first_rented_at, + MAX(COALESCE(b.end_date, b.start_date)) AS latest_rental_activity, + COALESCE(SUM( + CASE + WHEN b.status = 'active' + AND b.deleted_at IS NULL + AND s.status IN ('active', 'paused') + THEN COALESCE(i.line_total, 0) + ELSE 0 + END + ), 0) AS active_mrr, + COALESCE(SUM( + CASE + WHEN b.status = 'active' + AND b.deleted_at IS NULL + AND s.status IN ('draft', 'active', 'paused') + THEN COALESCE(i.line_total, 0) + ELSE 0 + END + ), 0) AS pipeline_mrr + FROM subscription_asset_bindings b + LEFT JOIN sag_subscriptions s ON s.id = b.subscription_id + LEFT JOIN sag_subscription_items i + ON i.subscription_id = s.id + AND i.asset_id = b.asset_id + WHERE b.asset_id = %s + """ + rental_overview = execute_query(rental_overview_query, (hardware_id,)) + rental_overview = (rental_overview or [{}])[0] + + rental_revenue_query = """ + SELECT + COALESCE(SUM( + CASE + WHEN o.sync_status IN ('exported', 'posted', 'paid') + THEN ((line->>'quantity')::numeric * (line->>'unit_price')::numeric) + ELSE 0 + END + ), 0) AS exported_or_posted_revenue, + COALESCE(SUM( + CASE + WHEN o.sync_status = 'pending' + THEN ((line->>'quantity')::numeric * (line->>'unit_price')::numeric) + ELSE 0 + END + ), 0) AS pending_revenue, + COALESCE(SUM((line->>'quantity')::numeric * (line->>'unit_price')::numeric), 0) AS total_revenue, + COUNT(DISTINCT o.id) AS order_count, + COUNT(DISTINCT CASE WHEN o.sync_status IN ('exported', 'posted', 'paid') THEN o.id END) AS exported_or_posted_order_count, + COUNT(DISTINCT CASE WHEN o.sync_status = 'pending' THEN o.id END) AS pending_order_count + FROM ordre_drafts o + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(o.lines_json, '[]'::jsonb)) line + WHERE line ? 'source_id' + AND (line->>'source_id') ~ '^[0-9]+$' + AND (line->>'source_id')::integer = %s + """ + rental_revenue = execute_query(rental_revenue_query, (hardware_id,)) + rental_revenue = (rental_revenue or [{}])[0] + + recent_rentals_query = """ + SELECT + o.id, + o.title, + o.sync_status, + o.created_at, + COALESCE(SUM((line->>'quantity')::numeric * (line->>'unit_price')::numeric), 0) AS amount + FROM ordre_drafts o + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(o.lines_json, '[]'::jsonb)) line + WHERE line ? 'source_id' + AND (line->>'source_id') ~ '^[0-9]+$' + AND (line->>'source_id')::integer = %s + GROUP BY o.id, o.title, o.sync_status, o.created_at + ORDER BY o.created_at DESC + LIMIT 8 + """ + recent_rentals = execute_query(recent_rentals_query, (hardware_id,)) + + rental_stats = { + "total_bindings": int(rental_overview.get("total_bindings") or 0), + "active_bindings": int(rental_overview.get("active_bindings") or 0), + "first_rented_at": rental_overview.get("first_rented_at"), + "latest_rental_activity": rental_overview.get("latest_rental_activity"), + "active_mrr": float(rental_overview.get("active_mrr") or 0), + "pipeline_mrr": float(rental_overview.get("pipeline_mrr") or 0), + "exported_or_posted_revenue": float(rental_revenue.get("exported_or_posted_revenue") or 0), + "pending_revenue": float(rental_revenue.get("pending_revenue") or 0), + "total_revenue": float(rental_revenue.get("total_revenue") or 0), + "order_count": int(rental_revenue.get("order_count") or 0), + "exported_or_posted_order_count": int(rental_revenue.get("exported_or_posted_order_count") or 0), + "pending_order_count": int(rental_revenue.get("pending_order_count") or 0), + } return templates.TemplateResponse("modules/hardware/templates/detail.html", { "request": request, @@ -581,7 +678,9 @@ async def hardware_detail(request: Request, hardware_id: int): "owner_customers": owner_customers or [], "owner_contacts": owner_contacts or [], "location_tree": location_tree or [], - "eset_specs": extract_eset_specs_summary(hardware) + "eset_specs": extract_eset_specs_summary(hardware), + "rental_stats": rental_stats, + "recent_rentals": recent_rentals or [], }) diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index 6edc041..a198623 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -105,6 +105,56 @@ margin-right: 0; padding-right: 0; } + + .price-tile { + background: var(--bg-card); + border: 1px solid rgba(0,0,0,0.08); + border-radius: 12px; + padding: 1rem; + height: 100%; + } + + .price-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.35rem; + } + + .price-value { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; + } + + .stat-tile { + background: var(--bg-card); + border: 1px solid rgba(0,0,0,0.08); + border-radius: 12px; + padding: 1rem; + height: 100%; + } + + .stat-label { + font-size: 0.82rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.35rem; + } + + .stat-value { + font-size: 1.3rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.15; + } + + .stat-helper { + margin-top: 0.4rem; + font-size: 0.82rem; + color: var(--text-secondary); + } {% endblock %} @@ -222,6 +272,16 @@ Historik + +