From 4a52bdb5d6851297197a8634c7bd3c757986d1aa Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 21 Apr 2026 01:34:40 +0200 Subject: [PATCH] feat: Implement quick-rent functionality for hardware assets - Added QuickRentCreateInput model to handle quick-rent requests. - Introduced quick_rent_preview endpoint to check existing subscriptions. - Created quick_rent_hardware endpoint to manage rental subscriptions, asset bindings, and startup order drafts. - Updated SQL queries to ensure proper data retrieval and handling. - Added default rental price columns to hardware_assets table via migration. - Enhanced UI in sag templates for better user experience and accessibility. - Refactored existing code for improved readability and maintainability. --- app/jobs/eset_sync.py | 13 +- app/modules/hardware/backend/router.py | 16 +- app/modules/hardware/templates/create.html | 31 ++ app/modules/hardware/templates/detail.html | 230 +++++++++++ app/modules/hardware/templates/edit.html | 59 +++ app/modules/orders/backend/router.py | 8 +- app/modules/rentals/backend/router.py | 364 ++++++++++++++++++ app/modules/sag/templates/detail.html | 222 ++++++++--- app/modules/sag/templates/index.html | 98 +++-- app/settings/backend/views.py | 48 ++- app/settings/frontend/migrations.html | 14 + .../170_hardware_default_rental_prices.sql | 17 + 12 files changed, 1019 insertions(+), 101 deletions(-) create mode 100644 migrations/170_hardware_default_rental_prices.sql diff --git a/app/jobs/eset_sync.py b/app/jobs/eset_sync.py index a68bf9c..5b9e65b 100644 --- a/app/jobs/eset_sync.py +++ b/app/jobs/eset_sync.py @@ -324,6 +324,16 @@ async def sync_eset_hardware() -> None: update_fields.append("brand = %s") update_params.append(brand) + # Auto-created ESET devices are customer devices by default unless explicitly reassigned. + if customer_id: + update_fields.append("current_owner_type = %s") + update_params.append("customer") + update_fields.append("current_owner_customer_id = %s") + update_params.append(customer_id) + elif existing[0].get("notes") == "Auto-created from ESET" and existing[0].get("current_owner_type") != "customer": + update_fields.append("current_owner_type = %s") + update_params.append("customer") + update_params.append(hardware_id) update_query = f""" UPDATE hardware_assets @@ -332,7 +342,8 @@ async def sync_eset_hardware() -> None: """ execute_query(update_query, tuple(update_params)) else: - owner_type = "customer" if customer_id else "bmc" + # ESET sync auto-creates customer endpoints; ownership can be refined later if needed. + owner_type = "customer" insert_query = """ INSERT INTO hardware_assets ( asset_type, brand, model, serial_number, diff --git a/app/modules/hardware/backend/router.py b/app/modules/hardware/backend/router.py index 238e9d1..c23207e 100644 --- a/app/modules/hardware/backend/router.py +++ b/app/modules/hardware/backend/router.py @@ -374,9 +374,11 @@ async def create_hardware(data: dict): internal_asset_id, notes, current_owner_type, current_owner_customer_id, status, status_reason, warranty_until, end_of_life, anydesk_id, anydesk_link, - eset_uuid, hardware_specs, eset_group + eset_uuid, hardware_specs, eset_group, + rental_default_start_price, rental_default_freight_price, + rental_default_preparation_price, rental_default_operations_monthly_price ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ @@ -402,7 +404,11 @@ async def create_hardware(data: dict): data.get("anydesk_link"), data.get("eset_uuid"), specs, - data.get("eset_group") + data.get("eset_group"), + data.get("rental_default_start_price"), + data.get("rental_default_freight_price"), + data.get("rental_default_preparation_price"), + data.get("rental_default_operations_monthly_price"), ) result = execute_query(query, params) if not result: @@ -503,7 +509,9 @@ async def update_hardware(hardware_id: int, data: dict): "internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id", "status", "status_reason", "warranty_until", "end_of_life", "follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link", - "eset_uuid", "hardware_specs", "eset_group" + "eset_uuid", "hardware_specs", "eset_group", + "rental_default_start_price", "rental_default_freight_price", + "rental_default_preparation_price", "rental_default_operations_monthly_price" ] for field in allowed_fields: diff --git a/app/modules/hardware/templates/create.html b/app/modules/hardware/templates/create.html index 48d4557..7433956 100644 --- a/app/modules/hardware/templates/create.html +++ b/app/modules/hardware/templates/create.html @@ -285,6 +285,30 @@ js{% extends "shared/frontend/base.html" %} + +
+

💶 Standardpriser for Udlejning

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Bruges til at autoudfylde Udlej-modal pa asseten.
+
+

📝 Noter

@@ -356,6 +380,13 @@ js{% extends "shared/frontend/base.html" %} // Convert customer_id to integer if (key === 'current_owner_customer_id') { data[key] = parseInt(value); + } else if ( + key === 'rental_default_start_price' || + key === 'rental_default_freight_price' || + key === 'rental_default_preparation_price' || + key === 'rental_default_operations_monthly_price' + ) { + data[key] = Number(value); } else { data[key] = value; } diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index 95ce2d7..6edc041 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -407,6 +407,12 @@ Opret Sag
+
+
+ + Udlej +
+
@@ -728,6 +734,99 @@
+ + + + +
+

💶 Standardpriser for Udlejning

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Bruges til at autoudfylde Udlej-modal pa asseten.
+
+

📝 Noter

@@ -329,6 +381,13 @@ // Convert customer_id to integer if (key === 'current_owner_customer_id') { data[key] = parseInt(value); + } else if ( + key === 'rental_default_start_price' || + key === 'rental_default_freight_price' || + key === 'rental_default_preparation_price' || + key === 'rental_default_operations_monthly_price' + ) { + data[key] = Number(value); } else { data[key] = value; } diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index 87dd089..bda971c 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -260,7 +260,7 @@ async def list_ordre_drafts( """List all ordre drafts (no user filtering).""" try: query = """ - SELECT id, title, customer_id, notes, layout_number, created_by_user_id, + SELECT ordre_drafts.id, ordre_drafts.title, ordre_drafts.customer_id, ordre_drafts.notes, ordre_drafts.layout_number, ordre_drafts.created_by_user_id, coverage_start, coverage_end, billing_direction, source_subscription_ids, invoice_aggregate_key, sync_status, export_idempotency_key, economic_order_number, economic_invoice_number, @@ -271,13 +271,13 @@ async def list_ordre_drafts( FROM ordre_draft_sync_events ev WHERE ev.draft_id = ordre_drafts.id ) AS sync_event_count, - last_sync_at, created_at, updated_at, last_exported_at + ordre_drafts.last_sync_at, ordre_drafts.created_at, ordre_drafts.updated_at, ordre_drafts.last_exported_at FROM ordre_drafts LEFT JOIN LATERAL ( - SELECT event_type, created_at + SELECT ordre_draft_sync_events.event_type, ordre_draft_sync_events.created_at FROM ordre_draft_sync_events WHERE draft_id = ordre_drafts.id - ORDER BY created_at DESC, id DESC + ORDER BY ordre_draft_sync_events.created_at DESC, ordre_draft_sync_events.id DESC LIMIT 1 ) ev_latest ON TRUE ORDER BY updated_at DESC, id DESC diff --git a/app/modules/rentals/backend/router.py b/app/modules/rentals/backend/router.py index 38c80c9..4f634a8 100644 --- a/app/modules/rentals/backend/router.py +++ b/app/modules/rentals/backend/router.py @@ -14,6 +14,7 @@ from app.core.database import execute_query, execute_query_single, get_db_connec from app.core.config import settings from app.jobs.process_subscriptions import process_subscriptions from app.subscriptions.backend.router import create_subscription as create_sag_subscription +from app.subscriptions.backend.router import create_subscription_asset_binding as create_sag_asset_binding from app.subscriptions.backend.router import update_subscription as update_sag_subscription logger = logging.getLogger(__name__) @@ -538,6 +539,102 @@ class AssetBindingCreateInput(BaseModel): created_by_user_id: Optional[int] = None +class QuickRentCreateInput(BaseModel): + customer_id: int + sag_id: int + start_date: date = Field(default_factory=date.today) + start_price: float = Field(default=0, ge=0) + freight_price: float = Field(default=0, ge=0) + preparation_price: float = Field(default=0, ge=0) + operations_monthly_price: float = Field(gt=0) + initial_operations_months: int = Field(default=2, ge=1, le=12) + notice_period_days: int = Field(default=30, ge=0) + created_by_user_id: Optional[int] = None + + +@router.get("/hardware/{hardware_id}/quick-rent/preview", response_model=Dict[str, Any]) +async def quick_rent_preview(hardware_id: int, customer_id: int = Query(...), sag_id: int = Query(...)): + """Preview whether quick-rent will reuse an existing subscription or create a new one.""" + hardware = execute_query_single( + """ + SELECT id, current_owner_type + FROM hardware_assets + WHERE id = %s + AND deleted_at IS NULL + """, + (hardware_id,), + ) + if not hardware: + return { + "can_submit": False, + "action": "blocked", + "message": "Hardware blev ikke fundet.", + } + + if (hardware.get("current_owner_type") or "").strip().lower() != "bmc": + return { + "can_submit": False, + "action": "blocked", + "message": "Kun BMC-ejede assets kan udlejes fra denne modal.", + } + + customer = execute_query_single( + "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL", + (customer_id,), + ) + if not customer: + return { + "can_submit": False, + "action": "blocked", + "message": "Kunde blev ikke fundet.", + } + + sag = execute_query_single( + "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (sag_id,), + ) + if not sag: + return { + "can_submit": False, + "action": "blocked", + "message": "Sag blev ikke fundet.", + } + + if int(sag.get("customer_id") or 0) != int(customer_id): + return { + "can_submit": False, + "action": "blocked", + "message": "Sag og kunde matcher ikke.", + } + + existing_subscription = execute_query_single( + """ + SELECT id, status + FROM sag_subscriptions + WHERE sag_id = %s + AND status IN ('draft', 'active', 'paused') + ORDER BY id DESC + LIMIT 1 + """, + (sag_id,), + ) + + if existing_subscription: + return { + "can_submit": True, + "action": "reuse", + "subscription_id": int(existing_subscription.get("id")), + "message": f"Genbruger abonnement #{int(existing_subscription.get('id'))} paa sagen.", + } + + return { + "can_submit": True, + "action": "create", + "subscription_id": None, + "message": "Der findes intet aktivt abonnement paa sagen. Der oprettes et nyt.", + } + + class InvoiceGenerateInput(BaseModel): preview: bool = False customer_id: Optional[int] = None @@ -706,6 +803,273 @@ async def create_subscription_alias(payload: SubscriptionCreateInput): return await create_sag_subscription(body) +@router.post("/hardware/{hardware_id}/quick-rent", response_model=Dict[str, Any]) +async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput): + """Create a rental subscription + asset binding + startup order draft in one step.""" + hardware = execute_query_single( + """ + SELECT id, brand, model, serial_number, current_owner_type + FROM hardware_assets + WHERE id = %s + AND deleted_at IS NULL + """, + (hardware_id,), + ) + if not hardware: + raise HTTPException(status_code=404, detail="Hardware not found") + + if (hardware.get("current_owner_type") or "").strip().lower() != "bmc": + raise HTTPException(status_code=409, detail="Only BMC-owned assets can be rented from this flow") + + customer = execute_query_single( + "SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL", + (payload.customer_id,), + ) + if not customer: + raise HTTPException(status_code=400, detail="Customer not found") + + sag = execute_query_single( + "SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (payload.sag_id,), + ) + if not sag: + raise HTTPException(status_code=400, detail="Sag not found") + + if int(sag.get("customer_id") or 0) != int(payload.customer_id): + raise HTTPException(status_code=400, detail="Sag customer mismatch") + + _assert_asset_booking_available( + asset_id=hardware_id, + start_date=payload.start_date, + end_date=None, + ) + + hardware_label = f"{hardware.get('brand') or ''} {hardware.get('model') or ''}".strip() or f"Asset {hardware_id}" + existing_subscription = execute_query_single( + """ + SELECT id, customer_id + FROM sag_subscriptions + WHERE sag_id = %s + AND status IN ('draft', 'active', 'paused') + ORDER BY id DESC + LIMIT 1 + """, + (payload.sag_id,), + ) + + created_new_subscription = False + if existing_subscription: + subscription_id = int(existing_subscription.get("id") or 0) + if int(existing_subscription.get("customer_id") or 0) != int(payload.customer_id): + raise HTTPException(status_code=400, detail="Existing subscription customer mismatch for sag") + + duplicate_line = execute_query_single( + """ + SELECT id + FROM sag_subscription_items + WHERE subscription_id = %s + AND asset_id = %s + LIMIT 1 + """, + (subscription_id, hardware_id), + ) + if duplicate_line: + raise HTTPException(status_code=409, detail="Asset already exists on subscription line items for this sag") + + next_line_row = execute_query_single( + "SELECT COALESCE(MAX(line_no), 0) + 1 AS next_line_no FROM sag_subscription_items WHERE subscription_id = %s", + (subscription_id,), + ) + next_line_no = int((next_line_row or {}).get("next_line_no") or 1) + + execute_query( + """ + INSERT INTO sag_subscription_items ( + subscription_id, + line_no, + product_id, + asset_id, + description, + quantity, + unit_price, + line_total, + period_from, + period_to, + price_type, + custom_price_override, + requires_serial_number, + serial_number, + billing_blocked, + billing_block_reason + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + subscription_id, + next_line_no, + None, + hardware_id, + f"Drift - {hardware_label}", + 1, + float(payload.operations_monthly_price), + float(payload.operations_monthly_price), + payload.start_date, + None, + "manual", + True, + False, + hardware.get("serial_number"), + False, + None, + ), + ) + + execute_query( + """ + UPDATE sag_subscriptions + SET price = COALESCE(price, 0) + %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (float(payload.operations_monthly_price), subscription_id), + ) + else: + subscription_payload = { + "customer_id": payload.customer_id, + "sag_id": payload.sag_id, + "product_name": f"Udlejning - {hardware_label}", + "price": float(payload.operations_monthly_price), + "billing_interval": "monthly", + "billing_day": min(max(payload.start_date.day, 1), 28), + "start_date": payload.start_date.isoformat(), + "end_date": None, + "binding_months": 0, + "billing_direction": "forward", + "price_type": "manual", + "custom_price_override": True, + "first_invoice_policy": "next_cycle", + "invoice_merge_key": f"rental-{payload.customer_id}-{payload.sag_id}", + "notes": f"Quick rent created from hardware #{hardware_id}", + "line_items": [ + { + "description": f"Drift - {hardware_label}", + "quantity": 1, + "unit_price": float(payload.operations_monthly_price), + "asset_id": hardware_id, + "serial_number": hardware.get("serial_number"), + "price_type": "manual", + "custom_price_override": True, + } + ], + } + + subscription = await create_sag_subscription(subscription_payload) + subscription_id = int(subscription.get("id") or 0) + if subscription_id <= 0: + raise HTTPException(status_code=500, detail="Subscription creation did not return an id") + created_new_subscription = True + + binding = await create_sag_asset_binding( + subscription_id, + { + "asset_id": hardware_id, + "start_date": payload.start_date.isoformat(), + "end_date": None, + "binding_months": 0, + "shared_binding_key": f"rental-{payload.customer_id}-{payload.sag_id}", + "notice_period_days": payload.notice_period_days, + "sag_id": payload.sag_id, + "created_by_user_id": payload.created_by_user_id, + }, + ) + + startup_lines: List[Dict[str, Any]] = [] + + def _append_line(key: str, description: str, quantity: float, unit_price: float) -> None: + if quantity <= 0 or unit_price < 0: + return + startup_lines.append( + { + "line_key": key, + "source_type": "manual", + "source_id": hardware_id, + "description": description, + "quantity": float(quantity), + "unit_price": float(unit_price), + "discount_percentage": 0, + "unit": "stk", + "product_id": None, + "selected": True, + } + ) + + _append_line("rental-start", f"Start - {hardware_label}", 1, float(payload.start_price)) + _append_line("rental-freight", "Fragt", 1, float(payload.freight_price)) + _append_line("rental-preparation", "Klargoring", 1, float(payload.preparation_price)) + _append_line( + "rental-operations-initial", + f"Drift (forste {payload.initial_operations_months} maned(er))", + float(payload.initial_operations_months), + float(payload.operations_monthly_price), + ) + + if not startup_lines: + raise HTTPException(status_code=400, detail="At least one startup/order line must have a price") + + coverage_end = payload.start_date + relativedelta(months=payload.initial_operations_months) + order_result = execute_query( + """ + INSERT INTO ordre_drafts ( + title, + customer_id, + lines_json, + notes, + coverage_start, + coverage_end, + billing_direction, + source_subscription_ids, + invoice_aggregate_key, + layout_number, + created_by_user_id, + sync_status, + export_status_json, + updated_at + ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) + RETURNING id, title, customer_id, sync_status, created_at + """, + ( + f"Udlejning opstart: {hardware_label}", + payload.customer_id, + json.dumps(startup_lines, ensure_ascii=False), + ( + f"Quick rent startup order for {hardware_label}\n" + f"Sag: #{payload.sag_id}\n" + f"Subscription: #{subscription_id}\n" + f"Asset: #{hardware_id}" + ), + payload.start_date, + coverage_end, + "forward", + [subscription_id], + f"quick-rent-{subscription_id}-{hardware_id}-{payload.start_date.isoformat()}", + 1, + payload.created_by_user_id, + "pending", + json.dumps({"source": "quick_rent", "subscription_id": subscription_id}, ensure_ascii=False), + ), + ) + order_draft = (order_result or [None])[0] + + return { + "status": "ok", + "hardware_id": hardware_id, + "subscription_id": subscription_id, + "created_new_subscription": created_new_subscription, + "asset_binding_id": binding.get("id") if isinstance(binding, dict) else None, + "ordre_draft_id": order_draft.get("id") if order_draft else None, + "message": "Rental flow completed: subscription, binding and startup order draft created", + } + + @router.put("/subscriptions/{subscription_id}", response_model=Dict[str, Any]) async def update_subscription_alias(subscription_id: int, payload: SubscriptionUpdateInput): body = payload.model_dump(exclude_none=True) diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 1e93107..443ed59 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -26,7 +26,6 @@ .time-v1-calendar-grid { display: flex; position: relative; - overflow-x: auto; } .time-v1-time-axis { width: 60px; @@ -67,7 +66,6 @@ justify-content: center; gap: 6px; position: sticky; - top: 0; z-index: 50; color: var(--text-color); } @@ -1518,12 +1516,59 @@ gap: 0.75rem; } + .module-priority-low { + --module-accent: #64748b; + } + + .module-priority-normal { + --module-accent: #0f4c75; + } + + .module-priority-high { + --module-accent: #d97706; + } + + .module-priority-critical { + --module-accent: #dc2626; + } + + .right-module-card { + border: 1px solid rgba(15, 76, 117, 0.14); + border-left: 4px solid var(--module-accent, var(--accent)); + border-radius: 12px; + overflow: hidden; + background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, var(--accent)) 6%, var(--bg-card)) 0%, var(--bg-card) 100%); + box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08); + } + + .right-module-card .card-header { + border-bottom: 1px solid color-mix(in srgb, var(--module-accent, var(--accent)) 22%, #d1d5db); + background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card)); + } + + .module-title { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.82rem; + font-weight: 700; + line-height: 1.2; + color: var(--text-primary); + } + + .module-icon { + color: var(--module-accent, var(--accent)); + font-size: 0.9rem; + flex-shrink: 0; + } + .right-modules-grid .card-header { padding: 0.35rem 0.6rem; } .right-modules-grid .card-header h6 { - font-size: 0.8rem; + font-size: 0.82rem; } .right-modules-grid .card-body { @@ -1689,7 +1734,7 @@ } .case-tabs-topbar.topbar-primary { - grid-template-columns: 105px minmax(170px, 1.1fr) minmax(170px, 1.1fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr); + grid-template-columns: 105px minmax(260px, 1.45fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr); background: linear-gradient(140deg, rgba(15,76,117,0.11), rgba(15,76,117,0.03)); border: 1px solid rgba(15,76,117,0.22); box-shadow: 0 3px 12px rgba(15,76,117,0.1); @@ -1735,6 +1780,21 @@ white-space: nowrap; } + .topbar-company-contact { + margin-top: 0.22rem; + font-size: 0.72rem; + line-height: 1.2; + color: color-mix(in srgb, var(--accent) 58%, #475569); + opacity: 0.95; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .topbar-company-contact i { + margin-right: 0.25rem; + } + .topbar-company-edit-btn { display: inline-flex; align-items: center; @@ -1841,6 +1901,25 @@ box-shadow: 0 1px 6px rgba(75,145,255,0.35); } + [data-bs-theme="dark"] .topbar-company-contact { + color: #b8d9f1; + } + + [data-bs-theme="dark"] .right-module-card { + border-color: rgba(140, 182, 219, 0.25); + box-shadow: 0 4px 16px rgba(5, 22, 40, 0.45); + background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%); + } + + [data-bs-theme="dark"] .right-module-card .card-header { + border-bottom-color: color-mix(in srgb, var(--module-accent, #69a6d5) 45%, #4b5563); + background: color-mix(in srgb, var(--module-accent, #69a6d5) 18%, rgba(18, 28, 40, 0.98)); + } + + [data-bs-theme="dark"] .module-title { + color: #e5edf5; + } + .case-tabs-topbar.topbar-secondary { /* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */ grid-template-columns: @@ -2261,17 +2340,18 @@
Firma
-
- {{ customer.name if customer else 'Ingen kunde' }} - +
+
+ {{ customer.name if customer else 'Ingen kunde' }} + +
+
+ Hoved kontakt: {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }} +
-
-
Kontakt
-
{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}
-
Status
-
+
-
🏷️ TAGS
+
Lokationer
+ +
+
+
+
Henter lokationer...
+
+
+
+ +
+
+
TAGS
-
+
-
🏢 Kunder
+
Kunder
@@ -5462,9 +5543,9 @@
-
+
-
👥 Kontakter
+
Kontakter
@@ -5511,9 +5592,9 @@
-
+
-
💻 Hardware
+
Hardware
-
+
-
📈 Salgspipeline
- +
Salgspipeline
+
+ + +
-
+
@@ -5617,9 +5703,9 @@
-
+
-
📞 Opkaldshistorik
+
Opkaldshistorik
@@ -5675,9 +5761,9 @@
-
+
-
✅ Todo-opgaver
+
Todo-opgaver
@@ -5697,9 +5783,9 @@
-
+
-
Kunde-wiki
+
Kunde-wiki
@@ -10788,10 +10874,20 @@ } function togglePipelineEdit(forceEdit = null) { + const moduleBody = document.getElementById('pipelineCardBody'); + const collapseIcon = document.getElementById('pipelineCollapseIcon'); const view = document.getElementById('pipelineViewMode'); const edit = document.getElementById('pipelineEditMode'); const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit; + if (moduleBody && moduleBody.classList.contains('d-none')) { + moduleBody.classList.remove('d-none'); + } + if (collapseIcon) { + collapseIcon.classList.remove('bi-chevron-down'); + collapseIcon.classList.add('bi-chevron-up'); + } + if (shouldEdit) { view.classList.add('d-none'); edit.classList.remove('d-none'); @@ -10805,6 +10901,28 @@ } } + function togglePipelineModule(forceOpen = null) { + const moduleBody = document.getElementById('pipelineCardBody'); + const collapseIcon = document.getElementById('pipelineCollapseIcon'); + if (!moduleBody) return; + + const shouldOpen = forceOpen === null ? moduleBody.classList.contains('d-none') : Boolean(forceOpen); + if (shouldOpen) { + moduleBody.classList.remove('d-none'); + if (collapseIcon) { + collapseIcon.classList.remove('bi-chevron-down'); + collapseIcon.classList.add('bi-chevron-up'); + } + return; + } + + moduleBody.classList.add('d-none'); + if (collapseIcon) { + collapseIcon.classList.remove('bi-chevron-up'); + collapseIcon.classList.add('bi-chevron-down'); + } + } + async function ensurePipelineStagesLoaded() { const select = document.getElementById('pipelineStageSelect'); if (!select) return; diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html index a1257f1..d171d58 100644 --- a/app/modules/sag/templates/index.html +++ b/app/modules/sag/templates/index.html @@ -5,13 +5,36 @@ {% block extra_css %}