From 3cddb71cecd7542fb4a2b81a7a9da195b5a5b10b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 17 Feb 2026 08:29:05 +0100 Subject: [PATCH] feat: Add Technician Dashboard V1, V2, and V3 with enhanced UI and functionality - Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview. - Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities. - Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management. - Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions. - Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths. - Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management. - Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql). - Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql). - Added a script to move time tracking section in detail view (move_time_section.py). - Created a test script for subscription processing (test_subscription_processing.py). --- app/core/config.py | 6 + app/customers/frontend/pipeline.html | 74 +- app/dashboard/backend/views.py | 290 ++++++- app/dashboard/frontend/sales.html | 115 +++ app/jobs/process_subscriptions.py | 216 +++++ app/modules/hardware/templates/detail.html | 217 ++++- app/modules/locations/frontend/views.py | 16 +- app/modules/locations/templates/list.html | 10 +- app/modules/orders/backend/economic_export.py | 197 +++++ app/modules/orders/backend/router.py | 280 ++++++ app/modules/orders/backend/service.py | 286 +++++++ app/modules/orders/frontend/views.py | 30 + app/modules/orders/templates/create.html | 726 ++++++++++++++++ app/modules/orders/templates/detail.html | 416 +++++++++ app/modules/orders/templates/list.html | 224 +++++ app/modules/sag/backend/router.py | 150 +++- app/modules/sag/frontend/views.py | 95 +- app/modules/sag/templates/create.html | 32 +- app/modules/sag/templates/detail.html | 677 +++++++++++++-- app/modules/sag/templates/edit.html | 22 +- app/modules/sag/templates/index.html | 76 +- app/modules/sag/templates/varekob_salg.html | 117 ++- app/modules/telefoni/backend/router.py | 16 + app/modules/telefoni/backend/service.py | 64 ++ app/opportunities/backend/router.py | 48 +- app/products/backend/router.py | 70 +- app/products/frontend/list.html | 28 +- app/services/simplycrm_service.py | 56 +- app/services/vtiger_service.py | 22 +- app/settings/backend/views.py | 22 +- app/settings/frontend/settings.html | 34 + app/shared/frontend/base.html | 4 +- app/subscriptions/backend/router.py | 808 +++++++++++++++++- app/subscriptions/frontend/list.html | 685 ++++++++++++++- app/subscriptions/frontend/list_backup.html | 192 +++++ .../frontend/simply_imports.html | 397 +++++++++ app/subscriptions/frontend/views.py | 8 + app/ticket/backend/router.py | 454 +++++++++- app/ticket/frontend/archived_ticket_list.html | 16 +- app/ticket/frontend/dashboard.html | 3 + .../frontend/mockups/tech_v1_overview.html | 120 +++ .../frontend/mockups/tech_v2_workboard.html | 127 +++ .../frontend/mockups/tech_v3_table_focus.html | 124 +++ .../technician_dashboard_selector.html | 107 +++ app/ticket/frontend/views.py | 336 +++++++- app/timetracking/backend/router.py | 13 +- compare_schemas.py | 2 +- main.py | 18 + migrations/130_user_dashboard_preferences.sql | 11 + migrations/131_sag_assignment_group.sql | 7 + migrations/132_subscription_cancellation.sql | 17 + migrations/133_ordre_drafts.sql | 24 + migrations/134_subscription_billing_dates.sql | 22 + .../135_subscription_extended_intervals.sql | 17 + .../136_simply_subscription_staging.sql | 56 ++ move_time_section.py | 213 +++++ static/js/telefoni.js | 52 ++ test_subscription_processing.py | 31 + 58 files changed, 8170 insertions(+), 326 deletions(-) create mode 100644 app/dashboard/frontend/sales.html create mode 100644 app/jobs/process_subscriptions.py create mode 100644 app/modules/orders/backend/economic_export.py create mode 100644 app/modules/orders/backend/router.py create mode 100644 app/modules/orders/backend/service.py create mode 100644 app/modules/orders/frontend/views.py create mode 100644 app/modules/orders/templates/create.html create mode 100644 app/modules/orders/templates/detail.html create mode 100644 app/modules/orders/templates/list.html create mode 100644 app/subscriptions/frontend/list_backup.html create mode 100644 app/subscriptions/frontend/simply_imports.html create mode 100644 app/ticket/frontend/mockups/tech_v1_overview.html create mode 100644 app/ticket/frontend/mockups/tech_v2_workboard.html create mode 100644 app/ticket/frontend/mockups/tech_v3_table_focus.html create mode 100644 app/ticket/frontend/technician_dashboard_selector.html create mode 100644 migrations/130_user_dashboard_preferences.sql create mode 100644 migrations/131_sag_assignment_group.sql create mode 100644 migrations/132_subscription_cancellation.sql create mode 100644 migrations/133_ordre_drafts.sql create mode 100644 migrations/134_subscription_billing_dates.sql create mode 100644 migrations/135_subscription_extended_intervals.sql create mode 100644 migrations/136_simply_subscription_staging.sql create mode 100644 move_time_section.py create mode 100644 test_subscription_processing.py diff --git a/app/core/config.py b/app/core/config.py index da4a43b..3c9c6bd 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -136,6 +136,12 @@ class Settings(BaseSettings): TIMETRACKING_EXPORT_TYPE: str = "draft" # "draft" or "booked" TIMETRACKING_ECONOMIC_LAYOUT: int = 19 # e-conomic invoice layout number (default: 19 = Danish standard) TIMETRACKING_ECONOMIC_PRODUCT: str = "1000" # e-conomic product number for time entries (default: 1000) + + # Global Ordre Module Safety Flags + ORDRE_ECONOMIC_READ_ONLY: bool = True + ORDRE_ECONOMIC_DRY_RUN: bool = True + ORDRE_ECONOMIC_LAYOUT: int = 19 + ORDRE_ECONOMIC_PRODUCT: str = "1000" # Simply-CRM (Old vTiger On-Premise) OLD_VTIGER_URL: str = "" diff --git a/app/customers/frontend/pipeline.html b/app/customers/frontend/pipeline.html index 749f4a5..03d7e94 100644 --- a/app/customers/frontend/pipeline.html +++ b/app/customers/frontend/pipeline.html @@ -136,7 +136,7 @@ async function loadStages() { } async function loadCustomers() { - const response = await fetch('/api/v1/customers?limit=10000'); + const response = await fetch('/api/v1/customers?limit=1000'); const data = await response.json(); customers = Array.isArray(data) ? data : (data.customers || []); @@ -158,20 +158,20 @@ function renderBoard() { return; } - board.innerHTML = stages.map(stage => { - const items = opportunities.filter(o => o.stage_id === stage.id); - const cards = items.map(o => ` + const renderCards = (items, stage) => { + return items.map(o => `
-
${escapeHtml(o.title)}
- ${o.probability || 0}% +
${escapeHtml(o.titel || '')}
+ ${o.pipeline_probability || 0}%
${escapeHtml(o.customer_name || '-')} - · ${formatCurrency(o.amount, o.currency)} + · ${formatCurrency(o.pipeline_amount, 'DKK')}
`).join(''); + }; - return ` + const unassignedItems = opportunities.filter(o => !o.pipeline_stage_id); + const columns = []; + + if (unassignedItems.length > 0) { + columns.push(` +
+
+ Ikke sat + ${unassignedItems.length} +
+ ${renderCards(unassignedItems, null)} +
+ `); + } + + stages.forEach(stage => { + const items = opportunities.filter(o => Number(o.pipeline_stage_id) === Number(stage.id)); + if (!items.length) return; + + columns.push(`
${stage.name} ${items.length}
- ${cards || '
Ingen muligheder
'} + ${renderCards(items, stage)}
- `; - }).join(''); + `); + }); + + if (!columns.length) { + board.innerHTML = '
Ingen muligheder i pipeline endnu
'; + return; + } + + board.innerHTML = columns.join(''); } async function changeStage(opportunityId, stageId) { - const response = await fetch(`/api/v1/opportunities/${opportunityId}/stage`, { + const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, { method: 'PATCH', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ stage_id: parseInt(stageId) }) + body: JSON.stringify({ stage_id: stageId ? parseInt(stageId, 10) : null }) }); if (!response.ok) { @@ -231,6 +259,7 @@ async function createOpportunity() { const response = await fetch('/api/v1/opportunities', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); @@ -240,12 +269,27 @@ async function createOpportunity() { return; } + const createdCase = await response.json(); + bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide(); await loadOpportunities(); + + if (createdCase?.id && (payload.stage_id || payload.amount)) { + await fetch(`/api/v1/sag/${createdCase.id}/pipeline`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stage_id: payload.stage_id || null, + amount: payload.amount || null + }) + }); + await loadOpportunities(); + } } function goToDetail(id) { - window.location.href = `/opportunities/${id}`; + window.location.href = `/sag/${id}`; } function formatCurrency(value, currency) { diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py index 502e853..8b07c6b 100644 --- a/app/dashboard/backend/views.py +++ b/app/dashboard/backend/views.py @@ -1,16 +1,106 @@ -from fastapi import APIRouter, Request +import logging + +from fastapi import APIRouter, Request, Form from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse -from app.core.database import execute_query_single +from fastapi.responses import HTMLResponse, RedirectResponse +from app.core.database import execute_query, execute_query_single router = APIRouter() templates = Jinja2Templates(directory="app") +logger = logging.getLogger(__name__) + +_DISALLOWED_DASHBOARD_PATHS = { + "/dashboard/default", + "/dashboard/default/clear", +} + + +def _sanitize_dashboard_path(value: str) -> str: + if not value: + return "" + candidate = value.strip() + if not candidate.startswith("/"): + return "" + if candidate.startswith("/api"): + return "" + if candidate.startswith("//"): + return "" + if candidate in _DISALLOWED_DASHBOARD_PATHS: + return "" + return candidate + + +def _get_user_default_dashboard(user_id: int) -> str: + try: + row = execute_query_single( + """ + SELECT default_dashboard_path + FROM user_dashboard_preferences + WHERE user_id = %s + """, + (user_id,) + ) + return _sanitize_dashboard_path((row or {}).get("default_dashboard_path", "")) + except Exception as exc: + if "user_dashboard_preferences" in str(exc): + logger.warning("⚠️ user_dashboard_preferences table not found; using fallback default dashboard") + return "" + raise + + +def _get_user_group_names(user_id: int): + rows = execute_query( + """ + SELECT LOWER(g.name) AS name + FROM user_groups ug + JOIN groups g ON g.id = ug.group_id + WHERE ug.user_id = %s + """, + (user_id,) + ) + return [r["name"] for r in (rows or []) if r.get("name")] + + +def _is_technician_group(group_names) -> bool: + return any( + "technician" in group or "teknik" in group + for group in (group_names or []) + ) + + +def _is_sales_group(group_names) -> bool: + return any( + "sales" in group or "salg" in group + for group in (group_names or []) + ) @router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): """ Render the dashboard page """ + user_id = getattr(request.state, "user_id", None) + preferred_dashboard = "" + + if user_id: + preferred_dashboard = _get_user_default_dashboard(int(user_id)) + + if not preferred_dashboard: + preferred_dashboard = _sanitize_dashboard_path(request.cookies.get("bmc_default_dashboard", "")) + + if preferred_dashboard and preferred_dashboard != "/": + return RedirectResponse(url=preferred_dashboard, status_code=302) + + if user_id: + group_names = _get_user_group_names(int(user_id)) + if _is_technician_group(group_names): + return RedirectResponse( + url=f"/ticket/dashboard/technician/v1?technician_user_id={int(user_id)}", + status_code=302 + ) + if _is_sales_group(group_names): + return RedirectResponse(url="/dashboard/sales", status_code=302) + # Fetch count of unknown billing worklogs unknown_query = """ SELECT COUNT(*) as count @@ -60,3 +150,197 @@ async def dashboard(request: Request): "bankruptcy_alerts": bankruptcy_alerts }) + +@router.get("/dashboard/sales", response_class=HTMLResponse) +async def sales_dashboard(request: Request): + pipeline_stats_query = """ + SELECT + COUNT(*) FILTER (WHERE s.status = 'åben') AS open_count, + COUNT(*) FILTER (WHERE s.status = 'lukket') AS closed_count, + COALESCE(SUM(COALESCE(s.pipeline_amount, 0)) FILTER (WHERE s.status = 'åben'), 0) AS open_value, + COALESCE(AVG(COALESCE(s.pipeline_probability, 0)) FILTER (WHERE s.status = 'åben'), 0) AS avg_probability + FROM sag_sager s + WHERE s.deleted_at IS NULL + AND ( + s.template_key = 'pipeline' + OR EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'case' + AND et.entity_id = s.id + AND LOWER(t.name) = 'pipeline' + ) + OR EXISTS ( + SELECT 1 + FROM sag_tags st + WHERE st.sag_id = s.id + AND st.deleted_at IS NULL + AND LOWER(st.tag_navn) = 'pipeline' + ) + ) + """ + + recent_opportunities_query = """ + SELECT + s.id, + s.titel, + s.status, + s.pipeline_amount, + s.pipeline_probability, + ps.name AS pipeline_stage, + s.deadline, + s.created_at, + COALESCE(c.name, 'Ukendt kunde') AS customer_name, + COALESCE(u.full_name, u.username, 'Ingen') AS owner_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id + WHERE s.deleted_at IS NULL + AND ( + s.template_key = 'pipeline' + OR EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'case' + AND et.entity_id = s.id + AND LOWER(t.name) = 'pipeline' + ) + OR EXISTS ( + SELECT 1 + FROM sag_tags st + WHERE st.sag_id = s.id + AND st.deleted_at IS NULL + AND LOWER(st.tag_navn) = 'pipeline' + ) + ) + ORDER BY s.created_at DESC + LIMIT 12 + """ + + due_soon_query = """ + SELECT + s.id, + s.titel, + s.deadline, + COALESCE(c.name, 'Ukendt kunde') AS customer_name, + COALESCE(u.full_name, u.username, 'Ingen') AS owner_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + WHERE s.deleted_at IS NULL + AND s.deadline IS NOT NULL + AND s.deadline BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '14 days') + AND ( + s.template_key = 'pipeline' + OR EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'case' + AND et.entity_id = s.id + AND LOWER(t.name) = 'pipeline' + ) + OR EXISTS ( + SELECT 1 + FROM sag_tags st + WHERE st.sag_id = s.id + AND st.deleted_at IS NULL + AND LOWER(st.tag_navn) = 'pipeline' + ) + ) + ORDER BY s.deadline ASC + LIMIT 8 + """ + + pipeline_stats = execute_query_single(pipeline_stats_query) or {} + recent_opportunities = execute_query(recent_opportunities_query) or [] + due_soon = execute_query(due_soon_query) or [] + + return templates.TemplateResponse( + "dashboard/frontend/sales.html", + { + "request": request, + "pipeline_stats": pipeline_stats, + "recent_opportunities": recent_opportunities, + "due_soon": due_soon, + "default_dashboard": _get_user_default_dashboard(getattr(request.state, "user_id", 0) or 0) + or request.cookies.get("bmc_default_dashboard", "") + } + ) + + +@router.post("/dashboard/default") +async def set_default_dashboard( + request: Request, + dashboard_path: str = Form(...), + redirect_to: str = Form(default="/") +): + safe_path = _sanitize_dashboard_path(dashboard_path) + safe_redirect = _sanitize_dashboard_path(redirect_to) or "/" + user_id = getattr(request.state, "user_id", None) + + response = RedirectResponse(url=safe_redirect, status_code=303) + if safe_path: + if user_id: + try: + execute_query( + """ + INSERT INTO user_dashboard_preferences (user_id, default_dashboard_path, updated_at) + VALUES (%s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) + DO UPDATE SET + default_dashboard_path = EXCLUDED.default_dashboard_path, + updated_at = CURRENT_TIMESTAMP + """, + (int(user_id), safe_path) + ) + except Exception as exc: + if "user_dashboard_preferences" in str(exc): + logger.warning("⚠️ Could not persist dashboard preference in DB (table missing); cookie fallback still active") + else: + raise + response.set_cookie( + key="bmc_default_dashboard", + value=safe_path, + httponly=True, + samesite="Lax" + ) + return response + + +@router.get("/dashboard/default") +async def set_default_dashboard_get_fallback(): + return RedirectResponse(url="/settings#system", status_code=303) + + +@router.post("/dashboard/default/clear") +async def clear_default_dashboard( + request: Request, + redirect_to: str = Form(default="/") +): + safe_redirect = _sanitize_dashboard_path(redirect_to) or "/" + user_id = getattr(request.state, "user_id", None) + if user_id: + try: + execute_query( + "DELETE FROM user_dashboard_preferences WHERE user_id = %s", + (int(user_id),) + ) + except Exception as exc: + if "user_dashboard_preferences" in str(exc): + logger.warning("⚠️ Could not clear DB dashboard preference (table missing); cookie fallback still active") + else: + raise + + response = RedirectResponse(url=safe_redirect, status_code=303) + response.delete_cookie("bmc_default_dashboard") + return response + + +@router.get("/dashboard/default/clear") +async def clear_default_dashboard_get_fallback(): + return RedirectResponse(url="/settings#system", status_code=303) + diff --git a/app/dashboard/frontend/sales.html b/app/dashboard/frontend/sales.html new file mode 100644 index 0000000..beec233 --- /dev/null +++ b/app/dashboard/frontend/sales.html @@ -0,0 +1,115 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Salg Dashboard - BMC Hub{% endblock %} + +{% block content %} +
+
+
+

💼 Salg Dashboard

+

Pipeline-overblik og opfølgning for salgsteamet

+
+ +
+ + + +
+
+
+
+
Åbne opportunities
+
{{ pipeline_stats.open_count or 0 }}
+
+
+
+
+
+
+
Lukkede opportunities
+
{{ pipeline_stats.closed_count or 0 }}
+
+
+
+
+
+
+
Åben pipeline værdi
+
{{ "{:,.0f}".format((pipeline_stats.open_value or 0)|float).replace(',', '.') }} kr.
+
+
+
+
+
+
+
Gns. sandsynlighed
+
{{ "%.0f"|format((pipeline_stats.avg_probability or 0)|float) }}%
+
+
+
+
+ +
+
+
+
Seneste opportunities
+
+
+ + + + + + + + + + + + + + {% for item in recent_opportunities %} + + + + + + + + + + {% else %} + + {% endfor %} + +
IDTitelKundeStageBeløbSandsynlighed
#{{ item.id }}{{ item.titel }}{{ item.customer_name }}{{ item.pipeline_stage or '-' }}{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%Åbn
Ingen opportunities fundet.
+
+
+
+
+ +
+
+
Deadline næste 14 dage
+
+ {% for item in due_soon %} +
+
{{ item.titel }}
+
{{ item.customer_name }} · {{ item.owner_name }}
+
Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}
+ Åbn +
+ {% else %} +

Ingen deadlines de næste 14 dage.

+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/jobs/process_subscriptions.py b/app/jobs/process_subscriptions.py new file mode 100644 index 0000000..217e59f --- /dev/null +++ b/app/jobs/process_subscriptions.py @@ -0,0 +1,216 @@ +""" +Subscription Invoice Processing Job +Processes active subscriptions when next_invoice_date is reached +Creates ordre drafts and advances subscription periods +Runs daily at 04:00 +""" + +import logging +from datetime import datetime, date +from decimal import Decimal +import json +from dateutil.relativedelta import relativedelta + +from app.core.config import settings +from app.core.database import execute_query, get_db_connection + +logger = logging.getLogger(__name__) + + +async def process_subscriptions(): + """ + Main job: Process subscriptions due for invoicing + - Find active subscriptions where next_invoice_date <= TODAY + - Create ordre draft with line items from subscription + - Advance period_start and next_invoice_date based on billing_interval + - Log all actions for audit trail + """ + + try: + logger.info("💰 Processing subscription invoices...") + + # Find subscriptions due for invoicing + query = """ + SELECT + s.id, + s.sag_id, + sg.titel AS sag_name, + s.customer_id, + c.name AS customer_name, + s.product_name, + s.billing_interval, + s.price, + s.next_invoice_date, + s.period_start, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', si.id, + 'description', si.description, + 'quantity', si.quantity, + 'unit_price', si.unit_price, + 'line_total', si.line_total, + 'product_id', si.product_id + ) ORDER BY si.id + ) + FROM sag_subscription_items si + WHERE si.subscription_id = s.id + ), + '[]'::json + ) as line_items + FROM sag_subscriptions s + LEFT JOIN sag_sager sg ON sg.id = s.sag_id + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.status = 'active' + AND s.next_invoice_date <= CURRENT_DATE + ORDER BY s.next_invoice_date, s.id + """ + + subscriptions = execute_query(query) + + if not subscriptions: + logger.info("✅ No subscriptions due for invoicing") + return + + logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process") + + processed_count = 0 + error_count = 0 + + for sub in subscriptions: + try: + await _process_single_subscription(sub) + processed_count += 1 + except Exception as e: + logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True) + error_count += 1 + + logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors") + + except Exception as e: + logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True) + + +async def _process_single_subscription(sub: dict): + """Process a single subscription: create ordre draft and advance period""" + + subscription_id = sub['id'] + logger.info(f"Processing subscription #{subscription_id}: {sub['product_name']} for {sub['customer_name']}") + + conn = get_db_connection() + cursor = conn.cursor() + + try: + # Convert line_items from JSON to list + line_items = sub.get('line_items', []) + if isinstance(line_items, str): + line_items = json.loads(line_items) + + # Build ordre draft lines_json + ordre_lines = [] + for item in line_items: + product_number = str(item.get('product_id', 'SUB')) + ordre_lines.append({ + "product": { + "productNumber": product_number, + "description": item.get('description', '') + }, + "quantity": float(item.get('quantity', 1)), + "unitNetPrice": float(item.get('unit_price', 0)), + "totalNetAmount": float(item.get('line_total', 0)), + "discountPercentage": 0 + }) + + # Create ordre draft title with period information + period_start = sub.get('period_start') or sub.get('next_invoice_date') + next_period_start = _calculate_next_period_start(period_start, sub['billing_interval']) + + title = f"Abonnement: {sub['product_name']}" + notes = f"Periode: {period_start} til {next_period_start}\nAbonnement ID: {subscription_id}" + + if sub.get('sag_id'): + notes += f"\nSag: {sub['sag_name']}" + + # Insert ordre draft + insert_query = """ + INSERT INTO ordre_drafts ( + title, + customer_id, + lines_json, + notes, + layout_number, + created_by_user_id, + export_status_json, + updated_at + ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) + RETURNING id + """ + + cursor.execute(insert_query, ( + title, + sub['customer_id'], + json.dumps(ordre_lines, ensure_ascii=False), + notes, + 1, # Default layout + None, # System-created + json.dumps({"source": "subscription", "subscription_id": subscription_id}, ensure_ascii=False) + )) + + ordre_id = cursor.fetchone()[0] + logger.info(f"✅ Created ordre draft #{ordre_id} for subscription #{subscription_id}") + + # Calculate new period dates + current_period_start = sub.get('period_start') or sub.get('next_invoice_date') + new_period_start = next_period_start + new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval']) + + # Update subscription with new period dates + update_query = """ + UPDATE sag_subscriptions + SET period_start = %s, + next_invoice_date = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """ + + cursor.execute(update_query, (new_period_start, new_next_invoice_date, subscription_id)) + + conn.commit() + logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}") + + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + +def _calculate_next_period_start(current_date, billing_interval: str) -> date: + """Calculate next period start date based on billing interval""" + + # Parse current_date if it's a string + if isinstance(current_date, str): + current_date = datetime.strptime(current_date, '%Y-%m-%d').date() + elif isinstance(current_date, datetime): + current_date = current_date.date() + + # Calculate delta based on interval + if billing_interval == 'daily': + delta = relativedelta(days=1) + elif billing_interval == 'biweekly': + delta = relativedelta(weeks=2) + elif billing_interval == 'monthly': + delta = relativedelta(months=1) + elif billing_interval == 'quarterly': + delta = relativedelta(months=3) + elif billing_interval == 'yearly': + delta = relativedelta(years=1) + else: + # Default to monthly if unknown + logger.warning(f"Unknown billing interval '{billing_interval}', defaulting to monthly") + delta = relativedelta(months=1) + + next_date = current_date + delta + return next_date diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index 1268217..95ce2d7 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -759,17 +759,21 @@
- +
Viser kun kontakter for valgt virksomhed.
@@ -1038,9 +1042,86 @@ document.addEventListener('DOMContentLoaded', function() { const ownerCustomerSearch = document.getElementById('ownerCustomerSearch'); const ownerCustomerSelect = document.getElementById('ownerCustomerSelect'); + const ownerContactSearch = document.getElementById('ownerContactSearch'); const ownerContactSelect = document.getElementById('ownerContactSelect'); const ownerCustomerHelp = document.getElementById('ownerCustomerHelp'); const ownerContactHelp = document.getElementById('ownerContactHelp'); + let ownerContactsCache = []; + const initialOwnerCustomerOptions = ownerCustomerSelect + ? Array.from(ownerCustomerSelect.options).map(option => ({ + value: option.value, + label: option.textContent, + selected: option.selected + })) + : []; + let ownerCustomerSearchTimeout; + + function renderOwnerCustomerOptions(items, keepValue = null) { + if (!ownerCustomerSelect) { + return; + } + + ownerCustomerSelect.innerHTML = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = '-- Vælg kunde --'; + ownerCustomerSelect.appendChild(placeholder); + + (items || []).forEach(item => { + const option = document.createElement('option'); + option.value = String(item.id); + option.textContent = item.name || item.navn || ''; + ownerCustomerSelect.appendChild(option); + }); + + if (keepValue) { + ownerCustomerSelect.value = String(keepValue); + } + } + + function restoreInitialOwnerCustomers() { + if (!ownerCustomerSelect) { + return; + } + + ownerCustomerSelect.innerHTML = ''; + initialOwnerCustomerOptions.forEach(item => { + const option = document.createElement('option'); + option.value = item.value; + option.textContent = item.label; + if (item.selected) { + option.selected = true; + } + ownerCustomerSelect.appendChild(option); + }); + } + + async function searchOwnerCustomersRemote(query) { + if (!ownerCustomerSelect) { + return; + } + + try { + const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error('Search request failed'); + } + + const rows = await response.json(); + const currentValue = ownerCustomerSelect.value; + renderOwnerCustomerOptions(rows || [], currentValue); + + if (ownerCustomerHelp) { + ownerCustomerHelp.textContent = rows && rows.length + ? `Viser ${rows.length} virksomhed(er).` + : 'Ingen virksomheder matcher søgningen.'; + } + } catch (error) { + if (ownerCustomerHelp) { + ownerCustomerHelp.textContent = 'Søgning fejlede. Prøv igen.'; + } + } + } function filterOwnerCustomers() { if (!ownerCustomerSearch || !ownerCustomerSelect) { @@ -1048,6 +1129,16 @@ } const filter = ownerCustomerSearch.value.toLowerCase().trim(); + + if (filter.length >= 2) { + clearTimeout(ownerCustomerSearchTimeout); + ownerCustomerSearchTimeout = setTimeout(() => { + searchOwnerCustomersRemote(filter); + }, 250); + return; + } + + restoreInitialOwnerCustomers(); const options = Array.from(ownerCustomerSelect.options); let visibleCount = 0; @@ -1079,56 +1170,104 @@ } } - function filterOwnerContacts() { - if (!ownerCustomerSelect || !ownerContactSelect) { + async function loadOwnerContactsForCustomer(customerId) { + if (!ownerContactSelect) { return; } - const selectedCustomerId = ownerCustomerSelect.value; - const options = Array.from(ownerContactSelect.options); - let visibleCount = 0; + ownerContactsCache = []; + ownerContactSelect.innerHTML = ''; - options.forEach((option, index) => { - if (index === 0) { - option.hidden = false; - return; + if (!customerId) { + ownerContactSelect.disabled = true; + if (ownerContactHelp) { + ownerContactHelp.textContent = 'Vælg først virksomhed.'; } - - const optionCustomerId = option.getAttribute('data-customer-id'); - const isVisible = selectedCustomerId && optionCustomerId === selectedCustomerId; - option.hidden = !isVisible; - if (isVisible) { - visibleCount += 1; - } - }); - - const selectedOption = ownerContactSelect.selectedOptions[0]; - if (!selectedOption || selectedOption.hidden) { - ownerContactSelect.value = ''; + return; } - ownerContactSelect.disabled = !selectedCustomerId || visibleCount === 0; + try { + const response = await fetch(`/api/v1/customers/${encodeURIComponent(customerId)}/contacts`); + if (!response.ok) { + throw new Error('Failed to load contacts'); + } + + const rows = await response.json(); + ownerContactsCache = rows || []; + + ownerContactSelect.disabled = !ownerContactsCache.length; + filterOwnerContactsSearch(); + if (ownerContactHelp) { + ownerContactHelp.textContent = ownerContactsCache.length + ? `Viser ${ownerContactsCache.length} kontaktperson(er) for valgt virksomhed.` + : 'Ingen kontaktpersoner fundet for valgt virksomhed.'; + } + } catch (error) { + ownerContactSelect.disabled = true; + ownerContactsCache = []; + if (ownerContactHelp) { + ownerContactHelp.textContent = 'Kunne ikke hente kontaktpersoner. Prøv igen.'; + } + } + } + + function filterOwnerContactsSearch() { + if (!ownerContactSelect || !ownerContactSearch) { + return; + } + + const filter = ownerContactSearch.value.toLowerCase().trim(); + const preferredContactId = ownerContactSelect.getAttribute('data-current-owner-contact-id'); + const currentValue = ownerContactSelect.value; + + const filteredContacts = ownerContactsCache.filter(contact => { + const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim().toLowerCase(); + const email = (contact.email || '').toLowerCase(); + const phone = (contact.phone || '').toLowerCase(); + return !filter || fullName.includes(filter) || email.includes(filter) || phone.includes(filter); + }); + + ownerContactSelect.innerHTML = ''; + filteredContacts.forEach(contact => { + const option = document.createElement('option'); + option.value = String(contact.id); + const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim(); + option.textContent = contact.email ? `${fullName} (${contact.email})` : fullName; + if ( + (currentValue && String(contact.id) === String(currentValue)) || + (!currentValue && preferredContactId && String(contact.id) === String(preferredContactId)) + ) { + option.selected = true; + } + ownerContactSelect.appendChild(option); + }); + if (ownerContactHelp) { - if (!selectedCustomerId) { + if (ownerContactSelect.disabled) { ownerContactHelp.textContent = 'Vælg først virksomhed.'; - } else if (visibleCount === 0) { - ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.'; + } else if (filteredContacts.length === 0) { + ownerContactHelp.textContent = 'Ingen kontaktpersoner matcher søgningen.'; } else { - ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.'; + ownerContactHelp.textContent = `Viser ${filteredContacts.length} kontaktperson(er) for valgt virksomhed.`; } } } if (ownerCustomerSelect && ownerContactSelect) { - ownerCustomerSelect.addEventListener('change', filterOwnerContacts); + ownerCustomerSelect.addEventListener('change', function() { + ownerContactSelect.setAttribute('data-current-owner-contact-id', ''); + loadOwnerContactsForCustomer(ownerCustomerSelect.value); + }); if (ownerCustomerSearch) { ownerCustomerSearch.addEventListener('input', function() { filterOwnerCustomers(); - filterOwnerContacts(); }); } + if (ownerContactSearch) { + ownerContactSearch.addEventListener('input', filterOwnerContactsSearch); + } filterOwnerCustomers(); - filterOwnerContacts(); + loadOwnerContactsForCustomer(ownerCustomerSelect.value); } if (window.renderEntityTags) { diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index c190eb7..c6eae02 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -147,7 +147,7 @@ def list_locations_view( is_active_bool = False # Query locations directly from database - where_clauses = [] + where_clauses = ["deleted_at IS NULL"] query_params = [] if location_type: @@ -272,7 +272,7 @@ def create_location_view(): parent_locations = execute_query(""" SELECT id, name, location_type FROM locations_locations - WHERE is_active = true + WHERE deleted_at IS NULL AND is_active = true ORDER BY name LIMIT 1000 """) @@ -322,12 +322,12 @@ def location_wizard_view(): logger.info("🧭 Rendering location wizard") parent_locations = execute_query(""" - SELECT id, name, location_type - FROM locations_locations - WHERE is_active = true - ORDER BY name - LIMIT 1000 - """) + SELECT id, name, location_type + FROM locations_locations + WHERE deleted_at IS NULL AND is_active = true + ORDER BY name + LIMIT 1000 + """) customers = execute_query(""" SELECT id, name, email, phone diff --git a/app/modules/locations/templates/list.html b/app/modules/locations/templates/list.html index 394106a..16363f1 100644 --- a/app/modules/locations/templates/list.html +++ b/app/modules/locations/templates/list.html @@ -508,12 +508,18 @@ document.addEventListener('DOMContentLoaded', function() { 'Content-Type': 'application/json' } }) - .then(response => { + .then(async response => { if (response.ok) { deleteModal.hide(); setTimeout(() => location.reload(), 300); } else { - alert('Fejl ved sletning af lokation'); + const err = await response.json().catch(() => ({})); + if (response.status === 404) { + alert(err.detail || 'Lokationen er allerede slettet. Siden opdateres.'); + setTimeout(() => location.reload(), 300); + return; + } + alert(err.detail || 'Fejl ved sletning af lokation'); } }) .catch(error => { diff --git a/app/modules/orders/backend/economic_export.py b/app/modules/orders/backend/economic_export.py new file mode 100644 index 0000000..74c1a4e --- /dev/null +++ b/app/modules/orders/backend/economic_export.py @@ -0,0 +1,197 @@ +import json +import logging +from datetime import date +from typing import Any, Dict, List, Optional + +import aiohttp +from fastapi import HTTPException + +from app.core.config import settings +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) + + +class OrdreEconomicExportService: + """e-conomic export service for global ordre page.""" + + def __init__(self): + self.api_url = settings.ECONOMIC_API_URL + self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN + self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN + + self.read_only = settings.ORDRE_ECONOMIC_READ_ONLY + self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN + self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT + self.default_product = settings.ORDRE_ECONOMIC_PRODUCT + + if self.read_only: + logger.warning("🔒 ORDRE e-conomic READ-ONLY mode: Enabled") + if self.dry_run: + logger.warning("🏃 ORDRE e-conomic DRY-RUN mode: Enabled") + if not self.read_only: + logger.error("⚠️ WARNING: ORDRE e-conomic READ-ONLY disabled!") + + def _headers(self) -> Dict[str, str]: + return { + "X-AppSecretToken": self.app_secret_token, + "X-AgreementGrantToken": self.agreement_grant_token, + "Content-Type": "application/json", + } + + def _check_write_permission(self, operation: str) -> bool: + if self.read_only: + logger.error("🚫 BLOCKED: %s - READ_ONLY mode enabled", operation) + return False + if self.dry_run: + logger.warning("🏃 DRY-RUN: %s - Would execute but not sending", operation) + return False + + logger.warning("⚠️ EXECUTING WRITE: %s", operation) + return True + + async def export_order( + self, + customer_id: int, + lines: List[Dict[str, Any]], + notes: Optional[str] = None, + layout_number: Optional[int] = None, + user_id: Optional[int] = None, + ) -> Dict[str, Any]: + customer = execute_query_single( + "SELECT id, name, economic_customer_number FROM customers WHERE id = %s", + (customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + if not customer.get("economic_customer_number"): + raise HTTPException( + status_code=400, + detail="Kunden mangler e-conomic kundenummer i Customers modulet", + ) + + selected_lines = [line for line in lines if bool(line.get("selected", True))] + if not selected_lines: + raise HTTPException(status_code=400, detail="Ingen linjer valgt til eksport") + + product_ids = [int(line["product_id"]) for line in selected_lines if line.get("product_id")] + product_map: Dict[int, str] = {} + if product_ids: + product_rows = execute_query( + "SELECT id, sku_internal FROM products WHERE id = ANY(%s)", + (product_ids,), + ) or [] + product_map = { + int(row["id"]): str(row["sku_internal"]) + for row in product_rows + if row.get("sku_internal") + } + + economic_lines: List[Dict[str, Any]] = [] + for line in selected_lines: + try: + quantity = float(line.get("quantity") or 0) + unit_price = float(line.get("unit_price") or 0) + discount = float(line.get("discount_percentage") or 0) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Ugyldige tal i linjer") + + if quantity <= 0: + raise HTTPException(status_code=400, detail="Linje quantity skal være > 0") + if unit_price < 0: + raise HTTPException(status_code=400, detail="Linje unit_price skal være >= 0") + + line_payload: Dict[str, Any] = { + "description": line.get("description") or "Ordrelinje", + "quantity": quantity, + "unitNetPrice": unit_price, + } + + product_id = line.get("product_id") + product_number = None + if product_id is not None: + try: + product_number = product_map.get(int(product_id)) + except (TypeError, ValueError): + product_number = None + + if not product_number: + product_number = self.default_product + + if product_number: + line_payload["product"] = {"productNumber": str(product_number)} + + if discount > 0: + line_payload["discountPercentage"] = discount + + economic_lines.append(line_payload) + + payload: Dict[str, Any] = { + "date": date.today().isoformat(), + "currency": "DKK", + "customer": { + "customerNumber": int(customer["economic_customer_number"]), + }, + "layout": { + "layoutNumber": int(layout_number or self.default_layout), + }, + "lines": economic_lines, + } + + if notes: + payload["notes"] = {"textLine1": str(notes)[:250]} + + operation = f"Export ordre for customer {customer_id} to e-conomic" + if not self._check_write_permission(operation): + return { + "success": True, + "dry_run": True, + "message": "DRY-RUN: Export blocked by safety flags", + "details": { + "customer_id": customer_id, + "customer_name": customer.get("name"), + "selected_line_count": len(selected_lines), + "read_only": self.read_only, + "dry_run": self.dry_run, + "user_id": user_id, + "payload": payload, + }, + } + + logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str)) + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.api_url}/orders/drafts", + headers=self._headers(), + json=payload, + timeout=aiohttp.ClientTimeout(total=30), + ) as response: + response_text = await response.text() + if response.status not in [200, 201]: + logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text) + raise HTTPException( + status_code=502, + detail=f"e-conomic export fejlede ({response.status})", + ) + + export_result = await response.json(content_type=None) + draft_number = export_result.get("draftOrderNumber") or export_result.get("orderNumber") + logger.info("✅ Ordre exported to e-conomic draft %s", draft_number) + + return { + "success": True, + "dry_run": False, + "message": f"Ordre eksporteret til e-conomic draft {draft_number}", + "economic_draft_id": draft_number, + "details": { + "customer_id": customer_id, + "customer_name": customer.get("name"), + "selected_line_count": len(selected_lines), + "user_id": user_id, + "economic_response": export_result, + }, + } + + +ordre_economic_export_service = OrdreEconomicExportService() diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py new file mode 100644 index 0000000..7d0cc07 --- /dev/null +++ b/app/modules/orders/backend/router.py @@ -0,0 +1,280 @@ +import logging +import json +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.modules.orders.backend.economic_export import ordre_economic_export_service +from app.modules.orders.backend.service import aggregate_order_lines + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class OrdreLineInput(BaseModel): + line_key: str + source_type: str + source_id: int + description: str + quantity: float = Field(gt=0) + unit_price: float = Field(ge=0) + discount_percentage: float = Field(default=0, ge=0, le=100) + unit: Optional[str] = None + product_id: Optional[int] = None + selected: bool = True + + +class OrdreExportRequest(BaseModel): + customer_id: int + lines: List[OrdreLineInput] + notes: Optional[str] = None + layout_number: Optional[int] = None + draft_id: Optional[int] = None + + +class OrdreDraftUpsertRequest(BaseModel): + title: str = Field(min_length=1, max_length=120) + customer_id: Optional[int] = None + lines: List[Dict[str, Any]] = Field(default_factory=list) + notes: Optional[str] = None + layout_number: Optional[int] = None + + +def _safe_json_field(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return value + + +def _get_user_id_from_request(http_request: Request) -> Optional[int]: + state_user_id = getattr(http_request.state, "user_id", None) + if state_user_id is None: + return None + try: + return int(state_user_id) + except (TypeError, ValueError): + return None + + +@router.get("/ordre/aggregate") +async def get_ordre_aggregate( + customer_id: Optional[int] = Query(None), + sag_id: Optional[int] = Query(None), + q: Optional[str] = Query(None), +): + """Aggregate global ordre lines from subscriptions, hardware and sales.""" + try: + return aggregate_order_lines(customer_id=customer_id, sag_id=sag_id, q=q) + except Exception as e: + logger.error("❌ Error aggregating ordre lines: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to aggregate ordre lines") + + +@router.get("/ordre/config") +async def get_ordre_config(): + """Return ordre module safety config for frontend banner.""" + return { + "economic_read_only": ordre_economic_export_service.read_only, + "economic_dry_run": ordre_economic_export_service.dry_run, + "default_layout": ordre_economic_export_service.default_layout, + "default_product": ordre_economic_export_service.default_product, + } + + +@router.post("/ordre/export") +async def export_ordre(request: OrdreExportRequest, http_request: Request): + """Export selected ordre lines to e-conomic draft order.""" + try: + user_id = _get_user_id_from_request(http_request) + + line_payload = [line.model_dump() for line in request.lines] + export_result = await ordre_economic_export_service.export_order( + customer_id=request.customer_id, + lines=line_payload, + notes=request.notes, + layout_number=request.layout_number, + user_id=user_id, + ) + + exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] + export_result["exported_line_keys"] = exported_line_keys + + if request.draft_id: + from app.core.database import execute_query_single, execute_query + + existing = execute_query_single("SELECT export_status_json FROM ordre_drafts WHERE id = %s", (request.draft_id,)) + existing_status = _safe_json_field((existing or {}).get("export_status_json")) or {} + if not isinstance(existing_status, dict): + existing_status = {} + + line_status = "dry-run" if export_result.get("dry_run") else "exported" + for line_key in exported_line_keys: + existing_status[line_key] = { + "status": line_status, + "timestamp": datetime.utcnow().isoformat(), + } + + execute_query( + """ + UPDATE ordre_drafts + SET export_status_json = %s::jsonb, + last_exported_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (json.dumps(existing_status, ensure_ascii=False), request.draft_id), + ) + + return export_result + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to export ordre") + + +@router.get("/ordre/drafts") +async def list_ordre_drafts( + http_request: Request, + limit: int = Query(25, ge=1, le=100) +): + """List all ordre drafts (no user filtering).""" + try: + query = """ + SELECT id, title, customer_id, notes, layout_number, created_by_user_id, + created_at, updated_at, last_exported_at + FROM ordre_drafts + ORDER BY updated_at DESC, id DESC + LIMIT %s + """ + params = (limit,) + + from app.core.database import execute_query + return execute_query(query, params) or [] + except Exception as e: + logger.error("❌ Error listing ordre drafts: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to list ordre drafts") + + +@router.get("/ordre/drafts/{draft_id}") +async def get_ordre_draft(draft_id: int, http_request: Request): + """Get single ordre draft with lines payload (no user filtering).""" + try: + query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1" + params = (draft_id,) + + from app.core.database import execute_query_single + draft = execute_query_single(query, params) + if not draft: + raise HTTPException(status_code=404, detail="Draft not found") + + draft["lines_json"] = _safe_json_field(draft.get("lines_json")) or [] + draft["export_status_json"] = _safe_json_field(draft.get("export_status_json")) or {} + return draft + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error fetching ordre draft: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to fetch ordre draft") + + +@router.post("/ordre/drafts") +async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request): + """Create a new ordre draft.""" + try: + user_id = _get_user_id_from_request(http_request) + from app.core.database import execute_query + + query = """ + INSERT INTO ordre_drafts ( + title, + customer_id, + lines_json, + notes, + layout_number, + created_by_user_id, + export_status_json, + updated_at + ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) + RETURNING * + """ + params = ( + request.title, + request.customer_id, + json.dumps(request.lines, ensure_ascii=False), + request.notes, + request.layout_number, + user_id, + json.dumps({}, ensure_ascii=False), + ) + result = execute_query(query, params) + return result[0] + except Exception as e: + logger.error("❌ Error creating ordre draft: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to create ordre draft") + + +@router.patch("/ordre/drafts/{draft_id}") +async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): + """Update existing ordre draft.""" + try: + from app.core.database import execute_query + + query = """ + UPDATE ordre_drafts + SET title = %s, + customer_id = %s, + lines_json = %s::jsonb, + notes = %s, + layout_number = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING * + """ + params = ( + request.title, + request.customer_id, + json.dumps(request.lines, ensure_ascii=False), + request.notes, + request.layout_number, + draft_id, + ) + + result = execute_query(query, params) + if not result: + raise HTTPException(status_code=404, detail="Draft not found") + return result[0] + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error updating ordre draft: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to update ordre draft") + + +@router.delete("/ordre/drafts/{draft_id}") +async def delete_ordre_draft(draft_id: int, http_request: Request): + """Delete ordre draft.""" + try: + from app.core.database import execute_query + + query = "DELETE FROM ordre_drafts WHERE id = %s RETURNING id" + params = (draft_id,) + + result = execute_query(query, params) + if not result: + raise HTTPException(status_code=404, detail="Draft not found") + return {"status": "deleted", "id": draft_id} + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to delete ordre draft") diff --git a/app/modules/orders/backend/service.py b/app/modules/orders/backend/service.py new file mode 100644 index 0000000..0f0dd7f --- /dev/null +++ b/app/modules/orders/backend/service.py @@ -0,0 +1,286 @@ +import logging +from typing import Any, Dict, List, Optional + +from app.core.database import execute_query + +logger = logging.getLogger(__name__) + + +def _to_float(value: Any, default: float = 0.0) -> float: + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _apply_common_filters( + base_query: str, + params: List[Any], + customer_id: Optional[int], + sag_id: Optional[int], + q: Optional[str], + customer_alias: str, + sag_alias: str, + description_alias: str, +) -> tuple[str, List[Any]]: + query = base_query + + if customer_id: + query += f" AND {customer_alias}.id = %s" + params.append(customer_id) + if sag_id: + query += f" AND {sag_alias}.id = %s" + params.append(sag_id) + if q: + like = f"%{q.lower()}%" + query += ( + f" AND (LOWER({description_alias}) LIKE %s" + f" OR LOWER(COALESCE({sag_alias}.titel, '')) LIKE %s" + f" OR LOWER(COALESCE({customer_alias}.name, '')) LIKE %s)" + ) + params.extend([like, like, like]) + + return query, params + + +def _fetch_sales_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]: + query = """ + SELECT + si.id, + si.sag_id, + s.titel AS sag_title, + s.customer_id, + c.name AS customer_name, + si.description, + si.quantity, + si.unit, + si.unit_price, + si.amount, + si.currency, + si.status, + si.line_date, + si.product_id + FROM sag_salgsvarer si + JOIN sag_sager s ON s.id = si.sag_id + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND LOWER(si.type) = 'sale' + AND LOWER(si.status) != 'cancelled' + """ + params: List[Any] = [] + query, params = _apply_common_filters(query, params, customer_id, sag_id, q, "c", "s", "si.description") + query += " ORDER BY si.line_date DESC NULLS LAST, si.id DESC" + + rows = execute_query(query, tuple(params)) or [] + lines: List[Dict[str, Any]] = [] + for row in rows: + qty = _to_float(row.get("quantity"), 0.0) + unit_price = _to_float(row.get("unit_price"), 0.0) + amount = _to_float(row.get("amount"), qty * unit_price) + lines.append( + { + "line_key": f"sale:{row['id']}", + "source_type": "sale", + "source_id": row["id"], + "reference_id": row["id"], + "sag_id": row.get("sag_id"), + "sag_title": row.get("sag_title"), + "customer_id": row.get("customer_id"), + "customer_name": row.get("customer_name"), + "description": row.get("description") or "Salgslinje", + "quantity": qty if qty > 0 else 1.0, + "unit": row.get("unit") or "stk", + "unit_price": unit_price, + "discount_percentage": 0.0, + "amount": amount, + "currency": row.get("currency") or "DKK", + "status": row.get("status") or "draft", + "line_date": str(row.get("line_date")) if row.get("line_date") else None, + "product_id": row.get("product_id"), + "selected": True, + } + ) + + return lines + + +def _fetch_subscription_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]: + query = """ + SELECT + i.id, + i.subscription_id, + i.line_no, + i.product_id, + i.description, + i.quantity, + i.unit_price, + i.line_total, + s.id AS sub_id, + s.subscription_number, + s.status AS subscription_status, + s.billing_interval, + s.sag_id, + sg.titel AS sag_title, + s.customer_id, + c.name AS customer_name + FROM sag_subscription_items i + JOIN sag_subscriptions s ON s.id = i.subscription_id + JOIN sag_sager sg ON sg.id = s.sag_id + LEFT JOIN customers c ON c.id = s.customer_id + WHERE sg.deleted_at IS NULL + AND LOWER(s.status) IN ('draft', 'active', 'paused') + """ + params: List[Any] = [] + query, params = _apply_common_filters(query, params, customer_id, sag_id, q, "c", "sg", "i.description") + query += " ORDER BY s.id DESC, i.line_no ASC, i.id ASC" + + rows = execute_query(query, tuple(params)) or [] + lines: List[Dict[str, Any]] = [] + for row in rows: + qty = _to_float(row.get("quantity"), 1.0) + unit_price = _to_float(row.get("unit_price"), 0.0) + amount = _to_float(row.get("line_total"), qty * unit_price) + lines.append( + { + "line_key": f"subscription:{row['id']}", + "source_type": "subscription", + "source_id": row["id"], + "reference_id": row.get("subscription_id"), + "subscription_number": row.get("subscription_number"), + "sag_id": row.get("sag_id"), + "sag_title": row.get("sag_title"), + "customer_id": row.get("customer_id"), + "customer_name": row.get("customer_name"), + "description": row.get("description") or "Abonnementslinje", + "quantity": qty if qty > 0 else 1.0, + "unit": "stk", + "unit_price": unit_price, + "discount_percentage": 0.0, + "amount": amount, + "currency": "DKK", + "status": row.get("subscription_status") or "draft", + "line_date": None, + "product_id": row.get("product_id"), + "selected": True, + "meta": { + "billing_interval": row.get("billing_interval"), + "line_no": row.get("line_no"), + }, + } + ) + + return lines + + +def _fetch_hardware_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]: + query = """ + SELECT + sh.id AS relation_id, + sh.sag_id, + sh.note, + s.titel AS sag_title, + s.customer_id, + c.name AS customer_name, + h.id AS hardware_id, + h.asset_type, + h.brand, + h.model, + h.serial_number, + h.status AS hardware_status + FROM sag_hardware sh + JOIN sag_sager s ON s.id = sh.sag_id + JOIN hardware_assets h ON h.id = sh.hardware_id + LEFT JOIN customers c ON c.id = s.customer_id + WHERE sh.deleted_at IS NULL + AND s.deleted_at IS NULL + AND h.deleted_at IS NULL + """ + params: List[Any] = [] + query, params = _apply_common_filters( + query, + params, + customer_id, + sag_id, + q, + "c", + "s", + "CONCAT(COALESCE(h.brand, ''), ' ', COALESCE(h.model, ''), ' ', COALESCE(h.serial_number, ''))", + ) + query += " ORDER BY sh.id DESC" + + rows = execute_query(query, tuple(params)) or [] + lines: List[Dict[str, Any]] = [] + for row in rows: + serial = row.get("serial_number") + serial_part = f" (S/N: {serial})" if serial else "" + brand_model = " ".join([part for part in [row.get("brand"), row.get("model")] if part]).strip() + label = brand_model or row.get("asset_type") or "Hardware" + desc = f"Hardware: {label}{serial_part}" + if row.get("note"): + desc = f"{desc} - {row['note']}" + + lines.append( + { + "line_key": f"hardware:{row['relation_id']}", + "source_type": "hardware", + "source_id": row["relation_id"], + "reference_id": row.get("hardware_id"), + "sag_id": row.get("sag_id"), + "sag_title": row.get("sag_title"), + "customer_id": row.get("customer_id"), + "customer_name": row.get("customer_name"), + "description": desc, + "quantity": 1.0, + "unit": "stk", + "unit_price": 0.0, + "discount_percentage": 0.0, + "amount": 0.0, + "currency": "DKK", + "status": row.get("hardware_status") or "active", + "line_date": None, + "product_id": None, + "selected": True, + } + ) + + return lines + + +def aggregate_order_lines( + customer_id: Optional[int] = None, + sag_id: Optional[int] = None, + q: Optional[str] = None, +) -> Dict[str, Any]: + """Aggregate order-ready lines across sale, subscription and hardware sources.""" + sales_lines = _fetch_sales_lines(customer_id=customer_id, sag_id=sag_id, q=q) + subscription_lines = _fetch_subscription_lines(customer_id=customer_id, sag_id=sag_id, q=q) + hardware_lines = _fetch_hardware_lines(customer_id=customer_id, sag_id=sag_id, q=q) + + all_lines = sales_lines + subscription_lines + hardware_lines + + total_amount = sum(_to_float(line.get("amount")) for line in all_lines) + selected_amount = sum(_to_float(line.get("amount")) for line in all_lines if line.get("selected")) + + customer_ids = sorted( + { + int(line["customer_id"]) + for line in all_lines + if line.get("customer_id") is not None + } + ) + + return { + "lines": all_lines, + "summary": { + "line_count": len(all_lines), + "line_count_sales": len(sales_lines), + "line_count_subscriptions": len(subscription_lines), + "line_count_hardware": len(hardware_lines), + "customer_count": len(customer_ids), + "total_amount": round(total_amount, 2), + "selected_amount": round(selected_amount, 2), + "currency": "DKK", + }, + } diff --git a/app/modules/orders/frontend/views.py b/app/modules/orders/frontend/views.py new file mode 100644 index 0000000..7b10684 --- /dev/null +++ b/app/modules/orders/frontend/views.py @@ -0,0 +1,30 @@ +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +logger = logging.getLogger(__name__) +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +@router.get("/ordre/create/new", response_class=HTMLResponse) +async def ordre_create(request: Request): + """Opret ny ordre (gammel funktionalitet).""" + return templates.TemplateResponse("modules/orders/templates/create.html", {"request": request}) + + +@router.get("/ordre/{draft_id}", response_class=HTMLResponse) +async def ordre_detail(request: Request, draft_id: int): + """Detaljeret visning af en specifik ordre.""" + return templates.TemplateResponse("modules/orders/templates/detail.html", { + "request": request, + "draft_id": draft_id + }) + + +@router.get("/ordre", response_class=HTMLResponse) +async def ordre_index(request: Request): + """Liste over alle ordre drafts.""" + return templates.TemplateResponse("modules/orders/templates/list.html", {"request": request}) diff --git a/app/modules/orders/templates/create.html b/app/modules/orders/templates/create.html new file mode 100644 index 0000000..7f62543 --- /dev/null +++ b/app/modules/orders/templates/create.html @@ -0,0 +1,726 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Ordre - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Opret ny ordre

+
Avanceret samlet ordrevisning (abonnement, hardware, salg)
+
+
+ Tilbage til liste + + + + + + +
+
+ +
+ + Safety mode aktiv: e-conomic eksport er read-only eller dry-run. +
+ +
+
+
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+
+
+ +
+
Linjer total
0
+
Valgte linjer
0
+
Beløb total
0 kr.
+
Valgt beløb
0 kr.
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
ValgKildeBeskrivelseAntalPrisRabat %BeløbEksportHandling
Indlæser...
+
+
+
+ +
+ +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/modules/orders/templates/detail.html b/app/modules/orders/templates/detail.html new file mode 100644 index 0000000..5f6b0dc --- /dev/null +++ b/app/modules/orders/templates/detail.html @@ -0,0 +1,416 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Ordre #{{ draft_id }} - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Ordre #{{ draft_id }}

+
Detaljeret visning
+
+
+ Tilbage til liste + + + +
+
+ +
+ + Safety mode aktiv: e-conomic eksport er read-only eller dry-run. +
+ +
+
+
+
+
Titel
+ +
+
+
+
+
Kunde ID
+ +
+
+
+
+
Layout nr.
+ +
+
+
+
+
Status
+
-
+
+
+
+
+
Noter
+ +
+
+
+
+ +
+
Antal linjer
0
+
Total beløb
0 kr.
+
Oprettet
-
+
Sidst opdateret
-
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
KildeBeskrivelseAntalEnhedsprisRabat %BeløbEnhedStatusHandling
Indlæser...
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/modules/orders/templates/list.html b/app/modules/orders/templates/list.html new file mode 100644 index 0000000..e9ad80c --- /dev/null +++ b/app/modules/orders/templates/list.html @@ -0,0 +1,224 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Ordre - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Ordre

+
Oversigt over alle ordre
+
+
+ Opret ny ordre + +
+
+ +
+
Total ordre
0
+
Seneste måned
0
+
Eksporteret
0
+
Ikke eksporteret
0
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
Ordre #TitelKundeLinjerOprettetSidst opdateretSidst eksporteretStatusHandlinger
Indlæser...
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index cc604b1..a9bf9ba 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -42,6 +42,63 @@ def _get_user_id_from_request(request: Request) -> int: raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter") + +def _normalize_case_status(status_value: Optional[str]) -> str: + if not status_value: + return "åben" + + normalized = str(status_value).strip().lower() + if normalized == "afventer": + return "åben" + if normalized in {"åben", "lukket"}: + return normalized + return "åben" + + +def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]: + if value is None: + return None + + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + + text = str(value).strip() + if not text: + return None + + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.replace(tzinfo=None) + return parsed.strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}") + + +def _coerce_optional_int(value: Optional[object], field_name: str) -> Optional[int]: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") + + +def _validate_user_id(user_id: Optional[int], field_name: str = "ansvarlig_bruger_id") -> None: + if user_id is None: + return + exists = execute_query("SELECT 1 FROM users WHERE user_id = %s", (user_id,)) + if not exists: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") + + +def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_group_id") -> None: + if group_id is None: + return + exists = execute_query("SELECT 1 FROM groups WHERE id = %s", (group_id,)) + if not exists: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") + # ============================================================================ # SAGER - CRUD Operations # ============================================================================ @@ -52,6 +109,7 @@ async def list_sager( tag: Optional[str] = Query(None), customer_id: Optional[int] = Query(None), ansvarlig_bruger_id: Optional[int] = Query(None), + assigned_group_id: Optional[int] = Query(None), include_deferred: bool = Query(False), q: Optional[str] = Query(None), limit: Optional[int] = Query(None, ge=1, le=200), @@ -59,28 +117,39 @@ async def list_sager( ): """List all cases with optional filtering.""" try: - query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" + query = """ + SELECT s.*, + COALESCE(u.full_name, u.username) AS ansvarlig_navn, + g.name AS assigned_group_name + FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN groups g ON g.id = s.assigned_group_id + WHERE s.deleted_at IS NULL + """ params = [] if not include_deferred: query += " AND (deferred_until IS NULL OR deferred_until <= NOW())" if status: - query += " AND status = %s" + query += " AND s.status = %s" params.append(status) if customer_id: - query += " AND customer_id = %s" + query += " AND s.customer_id = %s" params.append(customer_id) if ansvarlig_bruger_id: - query += " AND ansvarlig_bruger_id = %s" + query += " AND s.ansvarlig_bruger_id = %s" params.append(ansvarlig_bruger_id) + if assigned_group_id: + query += " AND s.assigned_group_id = %s" + params.append(assigned_group_id) if q: - query += " AND (LOWER(titel) LIKE %s OR CAST(id AS TEXT) LIKE %s)" + query += " AND (LOWER(s.titel) LIKE %s OR CAST(s.id AS TEXT) LIKE %s)" q_like = f"%{q.lower()}%" params.extend([q_like, q_like]) - query += " ORDER BY created_at DESC" + query += " ORDER BY s.created_at DESC" if limit is not None: query += " LIMIT %s OFFSET %s" @@ -162,14 +231,19 @@ async def create_sag(data: dict): if not data.get("customer_id"): raise HTTPException(status_code=400, detail="customer_id is required") - status = data.get("status", "åben") - if status not in {"åben", "lukket"}: - status = "åben" + status = _normalize_case_status(data.get("status")) + deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline") + deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until") + ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id") + assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id") + + _validate_user_id(ansvarlig_bruger_id) + _validate_group_id(assigned_group_id) query = """ INSERT INTO sag_sager - (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( @@ -178,10 +252,11 @@ async def create_sag(data: dict): data.get("template_key") or data.get("type", "ticket"), status, data.get("customer_id"), - data.get("ansvarlig_bruger_id"), + ansvarlig_bruger_id, + assigned_group_id, data.get("created_by_user_id", 1), - data.get("deadline"), - data.get("deferred_until"), + deadline, + deferred_until, data.get("deferred_until_case_id"), data.get("deferred_until_status"), ) @@ -199,7 +274,15 @@ async def create_sag(data: dict): async def get_sag(sag_id: int): """Get a specific case.""" try: - query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" + query = """ + SELECT s.*, + COALESCE(u.full_name, u.username) AS ansvarlig_navn, + g.name AS assigned_group_name + FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN groups g ON g.id = s.assigned_group_id + WHERE s.id = %s AND s.deleted_at IS NULL + """ result = execute_query(query, (sag_id,)) if not result: raise HTTPException(status_code=404, detail="Case not found") @@ -402,8 +485,32 @@ async def update_sag(sag_id: int, updates: dict): if "type" in updates and "template_key" not in updates: updates["template_key"] = updates.get("type") + if "status" in updates: + updates["status"] = _normalize_case_status(updates.get("status")) + if "deadline" in updates: + updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") + if "deferred_until" in updates: + updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until") + if "ansvarlig_bruger_id" in updates: + updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id") + _validate_user_id(updates["ansvarlig_bruger_id"]) + if "assigned_group_id" in updates: + updates["assigned_group_id"] = _coerce_optional_int(updates.get("assigned_group_id"), "assigned_group_id") + _validate_group_id(updates["assigned_group_id"]) + # Build dynamic update query - allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"] + allowed_fields = [ + "titel", + "beskrivelse", + "template_key", + "status", + "ansvarlig_bruger_id", + "assigned_group_id", + "deadline", + "deferred_until", + "deferred_until_case_id", + "deferred_until_status", + ] set_clauses = [] params = [] @@ -1036,8 +1143,15 @@ async def add_case_location(sag_id: int, data: dict): async def remove_case_location(sag_id: int, location_id: int): """Remove location from case.""" try: - query = "UPDATE sag_lokationer SET deleted_at = NOW() WHERE sag_id = %s AND location_id = %s RETURNING id" - result = execute_query(query, (sag_id, location_id)) + query = """ + UPDATE sag_lokationer + SET deleted_at = NOW() + WHERE sag_id = %s + AND deleted_at IS NULL + AND (location_id = %s OR id = %s) + RETURNING id + """ + result = execute_query(query, (sag_id, location_id, location_id)) if result: logger.info("✅ Location %s removed from case %s", location_id, sag_id) diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index d98bd6d..5ba7fd6 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -1,4 +1,6 @@ import logging +from datetime import date, datetime +from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates @@ -8,25 +10,78 @@ from app.core.database import execute_query logger = logging.getLogger(__name__) router = APIRouter() + +def _is_deadline_overdue(deadline_value) -> bool: + if not deadline_value: + return False + if isinstance(deadline_value, datetime): + return deadline_value.date() < date.today() + if isinstance(deadline_value, date): + return deadline_value < date.today() + return False + # Setup template directory templates = Jinja2Templates(directory="app") + +def _fetch_assignment_users(): + return execute_query( + """ + SELECT user_id, COALESCE(full_name, username) AS display_name + FROM users + ORDER BY display_name + """, + () + ) or [] + + +def _fetch_assignment_groups(): + return execute_query( + """ + SELECT id, name + FROM groups + ORDER BY name + """, + () + ) or [] + + +def _coerce_optional_int(value: Optional[str]) -> Optional[int]: + """Convert empty strings and None to None, otherwise parse as int.""" + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @router.get("/sag", response_class=HTMLResponse) async def sager_liste( request: Request, status: str = Query(None), tag: str = Query(None), - customer_id: int = Query(None), + customer_id: str = Query(None), + ansvarlig_bruger_id: str = Query(None), + assigned_group_id: str = Query(None), include_deferred: bool = Query(False), ): """Display list of all cases.""" try: + # Coerce string params to optional ints + customer_id_int = _coerce_optional_int(customer_id) + ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id) + assigned_group_id_int = _coerce_optional_int(assigned_group_id) query = """ SELECT s.*, c.name as customer_name, - CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn + CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, + COALESCE(u.full_name, u.username) AS ansvarlig_navn, + g.name AS assigned_group_name FROM sag_sager s LEFT JOIN customers c ON s.customer_id = c.id + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN groups g ON g.id = s.assigned_group_id LEFT JOIN LATERAL ( SELECT cc.contact_id FROM contact_companies cc @@ -50,9 +105,15 @@ async def sager_liste( if status: query += " AND s.status = %s" params.append(status) - if customer_id: + if customer_id_int: query += " AND s.customer_id = %s" - params.append(customer_id) + params.append(customer_id_int) + if ansvarlig_bruger_id_int: + query += " AND s.ansvarlig_bruger_id = %s" + params.append(ansvarlig_bruger_id_int) + if assigned_group_id_int: + query += " AND s.assigned_group_id = %s" + params.append(assigned_group_id_int) query += " ORDER BY s.created_at DESC" sager = execute_query(query, tuple(params)) @@ -119,6 +180,10 @@ async def sager_liste( "current_tag": tag, "include_deferred": include_deferred, "toggle_include_deferred_url": toggle_include_deferred_url, + "assignment_users": _fetch_assignment_users(), + "assignment_groups": _fetch_assignment_groups(), + "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, + "current_assigned_group_id": assigned_group_id_int, }) except Exception as e: logger.error("❌ Error displaying case list: %s", e) @@ -127,7 +192,11 @@ async def sager_liste( @router.get("/sag/new", response_class=HTMLResponse) async def opret_sag_side(request: Request): """Show create case form.""" - return templates.TemplateResponse("modules/sag/templates/create.html", {"request": request}) + return templates.TemplateResponse("modules/sag/templates/create.html", { + "request": request, + "assignment_users": _fetch_assignment_users(), + "assignment_groups": _fetch_assignment_groups(), + }) @router.get("/sag/varekob-salg", response_class=HTMLResponse) async def sag_varekob_salg(request: Request): @@ -141,7 +210,15 @@ async def sag_detaljer(request: Request, sag_id: int): """Display case details.""" try: # Fetch main case - sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" + sag_query = """ + SELECT s.*, + COALESCE(u.full_name, u.username) AS ansvarlig_navn, + g.name AS assigned_group_name + FROM sag_sager s + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + LEFT JOIN groups g ON g.id = s.assigned_group_id + WHERE s.id = %s AND s.deleted_at IS NULL + """ sag_result = execute_query(sag_query, (sag_id,)) if not sag_result: @@ -375,6 +452,7 @@ async def sag_detaljer(request: Request, sag_id: int): pipeline_stages = [] statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) + is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) return templates.TemplateResponse("modules/sag/templates/detail.html", { "request": request, @@ -398,6 +476,9 @@ async def sag_detaljer(request: Request, sag_id: int): "related_case_options": related_case_options, "pipeline_stages": pipeline_stages, "status_options": [s["status"] for s in statuses], + "is_deadline_overdue": is_deadline_overdue, + "assignment_users": _fetch_assignment_users(), + "assignment_groups": _fetch_assignment_groups(), }) except HTTPException: raise @@ -419,6 +500,8 @@ async def sag_rediger(request: Request, sag_id: int): return templates.TemplateResponse("modules/sag/templates/edit.html", { "request": request, "case": sag_result[0], + "assignment_users": _fetch_assignment_users(), + "assignment_groups": _fetch_assignment_groups(), }) except HTTPException: raise diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index ccef599..94fe9bf 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -238,7 +238,7 @@
Type, Status & Ansvar
-
+
-
+
- -
- -
- - -
+
+ +
+
+ + +
+
+ +
@@ -839,6 +852,7 @@ status: status, customer_id: selectedCustomer ? selectedCustomer.id : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, + assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null, created_by_user_id: 1, // HARDCODED for now, should come from auth deadline: document.getElementById('deadline').value || null }; diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index e35c50e..d1a517f 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -716,11 +716,20 @@ Opr: {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }} | Opd: {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }} - | - Deadline: - - {{ case.deadline.strftime('%d/%m-%y') if case.deadline else 'Ingen' }} - +
+ +
+ Deadline: + {% if case.deadline %} + + {{ case.deadline.strftime('%d/%m-%y') }} + + {% else %} + Ingen + {% endif %} +
@@ -741,6 +750,36 @@
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
Hvad betyder relationstyper?
+
Relateret til: Faglig kobling uden direkte afhængighed.
+
Afledt af: Denne sag er opstået på baggrund af en anden sag.
+
Årsag til: Denne sag er årsagen til en anden sag.
+
Blokkerer: Arbejde i en sag stopper fremdrift i den anden.
+
{% macro render_tree(nodes) %}
    {% for node in nodes %} @@ -936,16 +982,23 @@ {% if node.relation_type %} {% set rel_icon = 'bi-link-45deg' %} {% set rel_color = 'text-muted' %} + {% set rel_help = 'Faglig kobling uden direkte afhængighed' %} {% if node.relation_type == 'Afledt af' %} {% set rel_icon = 'bi-arrow-return-right' %} {% set rel_color = 'text-info' %} + {% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %} + {% elif node.relation_type == 'Årsag til' %} + {% set rel_icon = 'bi-arrow-right-circle' %} + {% set rel_color = 'text-primary' %} + {% set rel_help = 'Denne sag er årsag til en anden sag' %} {% elif node.relation_type == 'Blokkerer' %} {% set rel_icon = 'bi-slash-circle' %} {% set rel_color = 'text-danger' %} + {% set rel_help = 'Arbejdet i denne sag blokerer den anden' %} {% endif %} - + {{ node.relation_type }} @@ -1244,16 +1297,24 @@
    - - - - - - - + + + +
    + + + +
    +
    Betydning i praksis
    +
    Relateret til: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.
    +
    Afledt af: Bruges når denne sag er afledt af et tidligere problem/arbejde.
    +
    Årsag til: Bruges når denne sag skaber behovet for den anden.
    +
    Blokkerer: Bruges når løsning i én sag er nødvendig før den anden kan videre.
    +
    @@ -1350,6 +1411,37 @@
+ + + @@ -2459,10 +2615,14 @@ async function unlinkLocation(locId) { if(!confirm("Fjern link til denne lokation?")) return; try { - await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' }); + const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Kunne ikke fjerne lokation'); + } loadCaseLocations(); } catch (e) { - alert("Fejl ved sletning"); + alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl')); } } @@ -2505,6 +2665,121 @@ } } + + +
+
+
+
Tid & Fakturering
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ + + + + + + + + + + {% for entry in time_entries %} + + + + + + + {% else %} + + + + {% endfor %} + +
DatoBeskrivelseBrugerTimer
{{ entry.worked_date }}{{ entry.description or '-' }}{{ entry.user_name }}{{ entry.original_hours }}
+ Ingen tid registreret endnu +
+
+ + + {% if prepaid_cards %} +
+
Aktive Klippekort
+
+ {% for card in prepaid_cards %} +
+
+
Kort #{{ card.card_number or card.id }}
+
{{ '%.2f' % card.remaining_hours }} timer tilbage
+
+
+ {% endfor %} +
+
+ {% endif %} +
+
@@ -2655,57 +2930,6 @@
-
-
-
Tid & Fakturering
- -
-
-
- - - - - - - - - - - {% for entry in time_entries %} - - - - - - - {% else %} - - - - {% endfor %} - -
DatoBeskrivelseBrugerTimer
{{ entry.worked_date }}{{ entry.description or '-' }}{{ entry.user_name }}{{ entry.original_hours }}
Ingen tid registreret
-
-
-
Klippekort
- {% if prepaid_cards %} -
- {% for card in prepaid_cards %} -
- #{{ card.card_number or card.id }} - {{ '%.2f' % card.remaining_hours }}t -
- {% endfor %} -
- {% else %} -
Ingen aktive klippekort
- {% endif %} -
-
-
@@ -2967,29 +3191,37 @@
-
+
-
-
+
-
-
+
+ +
-
+
+
-
-
+
-
-
+
-
- -
-
+ +
-
+
+
+ +
-
@@ -3020,6 +3252,8 @@
+ + @@ -4660,13 +4896,21 @@
- + + + +
+
+
+
Sådan vælger du korrekt relation
+
Relateret til: Samme emne/område, men ingen direkte afhængighed.
+
Afledt af: Den nye sag opstår fordi den nuværende sag findes.
+
Årsag til: Den nuværende sag opstår fordi den nye sag findes.
+
Blokkerer: Løsning i én sag er nødvendig før den anden kan afsluttes.
+
@@ -4889,6 +5133,55 @@ } } + async function updateDeadline(value) { + try { + const res = await fetch(`/api/v1/sag/${caseIds}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ deadline: value || null }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Kunne ikke opdatere deadline'); + } + window.location.reload(); + } catch (e) { + alert('Fejl: ' + e.message); + } + } + + function shiftDeadlineDays(days) { + const input = document.getElementById('deadlineInput'); + const base = input.value ? new Date(input.value) : new Date(); + base.setDate(base.getDate() + days); + input.value = base.toISOString().slice(0, 10); + updateDeadline(input.value); + } + + function shiftDeadlineMonths(months) { + const input = document.getElementById('deadlineInput'); + const base = input.value ? new Date(input.value) : new Date(); + base.setMonth(base.getMonth() + months); + input.value = base.toISOString().slice(0, 10); + updateDeadline(input.value); + } + + function openDeadlineModal() { + const modal = new bootstrap.Modal(document.getElementById('deadlineModal')); + modal.show(); + } + + function saveDeadlineAll() { + const input = document.getElementById('deadlineInput'); + updateDeadline(input.value || null); + } + + function clearDeadlineAll() { + const input = document.getElementById('deadlineInput'); + input.value = ''; + updateDeadline(null); + } + function setDeferredFromInput() { const input = document.getElementById('deferredUntilInput'); updateDeferredUntil(input.value || null); @@ -4986,6 +5279,80 @@ view.classList.remove('d-none'); edit.classList.add('d-none'); } + + if (shouldEdit) { + ensurePipelineStagesLoaded(); + } + } + + async function ensurePipelineStagesLoaded() { + const select = document.getElementById('pipelineStageSelect'); + if (!select) return; + + if (select.options.length > 1) return; + + try { + const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' }); + if (!response.ok) return; + + const stages = await response.json(); + if (!Array.isArray(stages) || stages.length === 0) return; + + const existingValue = select.value || ''; + select.innerHTML = '' + + stages.map((stage) => ``).join(''); + if (existingValue) { + select.value = existingValue; + } + } catch (error) { + console.error('Could not load pipeline stages', error); + } + } + + async function saveAssignment() { + const statusEl = document.getElementById('assignmentStatus'); + const userValue = document.getElementById('assignmentUserSelect')?.value || ''; + const groupValue = document.getElementById('assignmentGroupSelect')?.value || ''; + + const payload = { + ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null, + assigned_group_id: groupValue ? parseInt(groupValue, 10) : null + }; + + if (statusEl) { + statusEl.textContent = 'Gemmer...'; + } + + try { + const response = await fetch(`/api/v1/sag/${caseId}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + let message = 'Kunne ikke gemme tildeling'; + try { + const data = await response.json(); + message = data.detail || message; + } catch (err) { + // Keep default message + } + if (statusEl) { + statusEl.textContent = `❌ ${message}`; + } + return; + } + + if (statusEl) { + statusEl.textContent = '✅ Tildeling gemt'; + } + } catch (err) { + if (statusEl) { + statusEl.textContent = `❌ ${err.message}`; + } + } } async function savePipeline() { @@ -5010,8 +5377,15 @@ }); if (!response.ok) { - const err = await response.json(); - throw new Error(err.detail || 'Kunne ikke opdatere pipeline'); + let message = 'Kunne ikke opdatere pipeline'; + try { + const err = await response.json(); + message = err.detail || err.message || message; + } catch (_e) { + const text = await response.text(); + if (text) message = text; + } + throw new Error(`${message} (HTTP ${response.status})`); } window.location.reload(); @@ -5065,10 +5439,18 @@ document.querySelectorAll('[data-module]').forEach((el) => { const moduleName = el.getAttribute('data-module'); const hasContent = moduleHasContent(el); - const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline'; + const isTimeModule = moduleName === 'time'; + const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule; const pref = modulePrefs[moduleName]; const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); + if (isTimeModule) { + el.classList.remove('d-none'); + el.classList.remove('module-empty-compact'); + if (tabButton) tabButton.classList.remove('d-none'); + return; + } + if (hasContent) { el.classList.remove('d-none'); el.classList.remove('module-empty-compact'); @@ -5150,6 +5532,7 @@ acc[p.module_key] = p.is_enabled; return acc; }, {}); + modulePrefs.time = true; } catch (e) { console.error('Module prefs load failed', e); } @@ -5185,12 +5568,14 @@ }); list.innerHTML = modules.map(m => { - const checked = modulePrefs[m.key] !== false; + const isTimeModule = m.key === 'time'; + const checked = isTimeModule ? true : modulePrefs[m.key] !== false; return `
- +
`; }).join(''); @@ -5200,6 +5585,11 @@ } async function toggleModulePref(moduleKey, isEnabled) { + if (moduleKey === 'time') { + modulePrefs.time = true; + applyViewFromTags(); + return; + } try { const res = await fetch(`/api/v1/sag/${caseIds}/modules`, { method: 'POST', @@ -5579,6 +5969,8 @@ function formatSubscriptionInterval(interval) { const map = { + 'daily': 'Daglig', + 'biweekly': '14-dage', 'monthly': 'Maaned', 'quarterly': 'Kvartal', 'yearly': 'Aar' @@ -5855,6 +6247,24 @@ document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price); document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date); document.getElementById('subscriptionStatusText').textContent = subscription.status || '-'; + + // New fields + const periodStartEl = document.getElementById('subscriptionPeriodStart'); + const nextInvoiceEl = document.getElementById('subscriptionNextInvoice'); + if (periodStartEl) { + periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-'; + } + if (nextInvoiceEl) { + const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-'; + nextInvoiceEl.textContent = nextDate; + // Highlight if invoice is due soon + if (subscription.next_invoice_date) { + const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24)); + if (daysUntil <= 7 && daysUntil >= 0) { + nextInvoiceEl.innerHTML = `${nextDate} Om ${daysUntil} dage`; + } + } + } setSubscriptionBadge(subscription.status); @@ -5898,7 +6308,7 @@ async function loadSubscriptionForCase() { try { - const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`); + const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`); if (res.status === 404) { showSubscriptionCreateForm(); setModuleContentState('subscription', false); @@ -5935,7 +6345,7 @@ } try { - const res = await fetch('/api/v1/subscriptions', { + const res = await fetch('/api/v1/sag-subscriptions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -5967,7 +6377,7 @@ } try { - const res = await fetch(`/api/v1/subscriptions/${currentSubscription.id}/status`, { + const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) @@ -5989,6 +6399,99 @@ loadSubscriptionProducts(); loadSubscriptionForCase(); }); + + // === Quick Time Entry Functions (for inline time tracking) === + function toggleQuickTimeForm() { + const container = document.getElementById('quickTimeFormContainer'); + if (container) { + container.classList.remove('d-none'); + } + } + + // Make function globally available for onclick handler + window.toggleQuickTimeForm = toggleQuickTimeForm; + + async function quickAddTime(event) { + event.preventDefault(); + + const form = document.getElementById('quickAddTimeForm'); + const formData = new FormData(form); + + // Parse hours and minutes + const hours = parseInt(formData.get('hours')) || 0; + const minutes = parseInt(formData.get('minutes')) || 0; + const totalHours = hours + (minutes / 60); + + if (totalHours === 0) { + alert('Angiv venligst timer eller minutter'); + return; + } + + const billingSelect = document.getElementById('quickTimeBillingMethod'); + let billingMethod = billingSelect ? billingSelect.value : 'invoice'; + let prepaidCardId = null; + let fixedPriceAgreementId = null; + + if (billingMethod.startsWith('card_')) { + prepaidCardId = parseInt(billingMethod.split('_')[1]); + billingMethod = 'prepaid'; + } + + if (billingMethod.startsWith('fpa_')) { + fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]); + billingMethod = 'fixed_price'; + } + + const isInternal = billingMethod === 'internal'; + + // Build payload + const payload = { + sag_id: {{ case.id }}, + worked_date: formData.get('date'), + original_hours: totalHours, + description: formData.get('description'), + billing_method: billingMethod, + is_internal: isInternal + }; + + if (prepaidCardId) { + payload.prepaid_card_id = prepaidCardId; + } + + if (fixedPriceAgreementId) { + payload.fixed_price_agreement_id = fixedPriceAgreementId; + } + + try { + const response = await fetch('/api/v1/timetracking/entries/internal', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering'); + } + + // Success - reload page to show new entry + window.location.reload(); + } catch (error) { + alert('Fejl: ' + error.message); + console.error('Quick add time error:', error); + } + } + + // Set today's date as default for quick time form + document.addEventListener('DOMContentLoaded', function() { + const dateInput = document.getElementById('quickTimeDate'); + if (dateInput && !dateInput.value) { + const today = new Date().toISOString().split('T')[0]; + dateInput.value = today; + } + });
diff --git a/app/modules/sag/templates/edit.html b/app/modules/sag/templates/edit.html index 326320b..db243a7 100644 --- a/app/modules/sag/templates/edit.html +++ b/app/modules/sag/templates/edit.html @@ -212,13 +212,28 @@
- - + + +
+ +
+ +
- +
@@ -278,6 +293,7 @@ type: type, status: status, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, + assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null, deadline: document.getElementById('deadline').value || null }; diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html index f3c4aa0..4dfd82d 100644 --- a/app/modules/sag/templates/index.html +++ b/app/modules/sag/templates/index.html @@ -298,6 +298,27 @@
+
+
+ +
+
+ +
+ {% if include_deferred %} + + {% endif %} +
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %} @@ -314,6 +335,8 @@ Type Kunde Hovedkontakt + Ansvarlig + Gruppe Status Udsat start Oprettet @@ -327,7 +350,7 @@ + data-type="{{ sag.template_key or sag.type or 'ticket' }}"> {% if has_relations %} + @@ -341,7 +364,7 @@ {% endif %} - {{ sag.type or 'ticket' }} + {{ sag.template_key or sag.type or 'ticket' }} {{ sag.customer_name if sag.customer_name else '-' }} @@ -349,6 +372,12 @@ {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }} + + {{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }} + + + {{ sag.assigned_group_name if sag.assigned_group_name else '-' }} + {{ sag.status }} @@ -369,7 +398,7 @@ {% if related_sag and rel.target_id not in seen_targets %} {% set _ = seen_targets.append(rel.target_id) %} {% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %} - + #{{ related_sag.id }} @@ -383,7 +412,7 @@ {% endif %} - {{ related_sag.type or 'ticket' }} + {{ related_sag.template_key or related_sag.type or 'ticket' }} {{ related_sag.customer_name if related_sag.customer_name else '-' }} @@ -391,6 +420,12 @@ {{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }} + + {{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }} + + + {{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }} + {{ related_sag.status }} @@ -449,6 +484,17 @@ let currentFilter = 'all'; let currentType = 'all'; + const assigneeFilter = document.getElementById('assigneeFilter'); + const groupFilter = document.getElementById('groupFilter'); + const assignmentFilterForm = document.getElementById('assignmentFilterForm'); + + if (assigneeFilter && assignmentFilterForm) { + assigneeFilter.addEventListener('change', () => assignmentFilterForm.submit()); + } + if (groupFilter && assignmentFilterForm) { + groupFilter.addEventListener('change', () => assignmentFilterForm.submit()); + } + function applyFilters() { const search = currentSearch; @@ -512,14 +558,26 @@ async function loadTypeFilters() { if (!typeFilter) return; try { + const rowTypes = new Set(Array.from(document.querySelectorAll('.tree-row[data-type], .tree-child[data-type]')) + .map((row) => (row.dataset.type || '').trim()) + .filter(Boolean)); + const res = await fetch('/api/v1/settings/case_types'); - if (!res.ok) return; - const setting = await res.json(); - const types = JSON.parse(setting.value || '[]'); - if (!Array.isArray(types) || types.length === 0) return; + let configuredTypes = []; + if (res.ok) { + const setting = await res.json(); + const types = JSON.parse(setting.value || '[]'); + if (Array.isArray(types)) { + configuredTypes = types; + } + } + + configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim())); + const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da')); + if (mergedTypes.length === 0) return; typeFilter.innerHTML = `` + - types.map(type => ``).join(''); + mergedTypes.map(type => ``).join(''); } catch (err) { console.error('Failed to load case types', err); } diff --git a/app/modules/sag/templates/varekob_salg.html b/app/modules/sag/templates/varekob_salg.html index 125c27f..dc6c4f8 100644 --- a/app/modules/sag/templates/varekob_salg.html +++ b/app/modules/sag/templates/varekob_salg.html @@ -47,6 +47,29 @@ border: 1px solid rgba(0,0,0,0.1); background: var(--bg-light); } + .table-secondary { + background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important; + cursor: pointer; + transition: background-color 0.2s; + } + .table-secondary:hover { + background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important; + } + .table-secondary td { + padding: 0.75rem !important; + } + .case-lines-container { + display: none; + } + .case-lines-container.show { + display: table-row; + } + .expand-icon { + transition: transform 0.3s; + } + .expand-icon.expanded { + transform: rotate(90deg); + } {% endblock %} @@ -142,9 +165,9 @@ + - @@ -172,9 +195,9 @@
Dato BeskrivelseSag Kunde Antal Enhed
+ - @@ -216,23 +239,83 @@ return; } - tbody.innerHTML = items.map(item => { - const statusLabel = item.status || 'draft'; - const caseLink = item.sag_id ? `${item.sag_titel || 'Sag ' + item.sag_id}` : '-'; - return ` - - - - - - - - - - + // Group items by case (sag_id) + const grouped = {}; + items.forEach((item, originalIndex) => { + const caseKey = item.sag_id || 'ingen-sag'; + if (!grouped[caseKey]) { + grouped[caseKey] = { + sag_id: item.sag_id || null, + sag_titel: item.sag_titel || 'Ingen sag', + items: [] + }; + } + grouped[caseKey].items.push({ ...item, originalIndex }); + }); + + // Render grouped rows with collapsible structure + let html = ''; + Object.keys(grouped).forEach((caseKey, groupIndex) => { + const group = grouped[caseKey]; + const groupTotal = group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0); + const tableId = tbodyId.replace('Body', ''); // Extract table identifier + const groupId = `${tableId}-case-${groupIndex}`; + + // Case header row (clickable to expand/collapse) + const caseLink = group.sag_id + ? `${group.sag_titel} Sag ${group.sag_id}` + : `${group.sag_titel}`; + + html += ` + + + `; - }).join(''); + + // Render lines in this case (hidden by default) + group.items.forEach(item => { + const statusLabel = item.status || 'draft'; + html += ` + + + + + + + + + + + + `; + }); + }); + + tbody.innerHTML = html; + } + + function toggleCaseLines(caseId) { + const lines = document.querySelectorAll(`tr[data-case="${caseId}"]`); + const icon = document.getElementById(`icon-${caseId}`); + + lines.forEach(line => { + line.classList.toggle('show'); + }); + + if (icon) { + icon.classList.toggle('expanded'); + } } async function loadOrders() { diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py index 84fb8fd..bce779b 100644 --- a/app/modules/telefoni/backend/router.py +++ b/app/modules/telefoni/backend/router.py @@ -217,6 +217,11 @@ async def yealink_established( kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8) kontakt_id = kontakt.get("id") if kontakt else None + # Get extended contact details if we found a contact + contact_details = {} + if kontakt_id: + contact_details = TelefoniService.get_contact_details(kontakt_id) + payload = { "callid": resolved_callid, "call_id": call_id, @@ -252,6 +257,8 @@ async def yealink_established( "number": ekstern_e164 or (ekstern_raw or ""), "direction": direction, "contact": kontakt, + "recent_cases": contact_details.get("recent_cases", []), + "last_call": contact_details.get("last_call"), }, ) else: @@ -395,6 +402,15 @@ async def telefoni_test_popup( "name": "Test popup", "company": "BMC Hub", }, + "recent_cases": [ + {"id": 1, "titel": "Test sag 1", "created_at": datetime.utcnow()}, + {"id": 2, "titel": "Test sag 2", "created_at": datetime.utcnow()}, + ], + "last_call": { + "started_at": datetime.utcnow(), + "bruger_navn": "Test Medarbejder", + "duration_sec": 125, + }, }, ) return { diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py index 0069f87..3642ecb 100644 --- a/app/modules/telefoni/backend/service.py +++ b/app/modules/telefoni/backend/service.py @@ -109,3 +109,67 @@ class TelefoniService: (duration_sec, callid), ) return bool(rows) + + @staticmethod + def get_contact_details(contact_id: int) -> dict: + """ + Get extended contact details including: + - Latest 3 open cases + - Last call date + """ + if not contact_id: + return {"recent_cases": [], "last_call": None} + + # Get the 3 newest open cases for this contact + cases_query = """ + SELECT + s.id, + s.titel, + s.created_at + FROM sag_sager s + INNER JOIN sag_kontakter sk ON s.id = sk.sag_id + WHERE sk.contact_id = %s + AND s.status = 'åben' + AND s.deleted_at IS NULL + AND sk.deleted_at IS NULL + ORDER BY s.created_at DESC + LIMIT 3 + """ + cases = execute_query(cases_query, (contact_id,)) or [] + + # Get the most recent call for this contact + last_call_query = """ + SELECT + t.started_at, + t.bruger_id, + t.duration_sec, + u.full_name, + u.username + FROM telefoni_opkald t + LEFT JOIN users u ON t.bruger_id = u.user_id + WHERE t.kontakt_id = %s + AND t.ended_at IS NOT NULL + ORDER BY t.started_at DESC + LIMIT 1 + """ + last_call_row = execute_query_single(last_call_query, (contact_id,)) + + last_call_data = None + if last_call_row: + last_call_data = { + "started_at": last_call_row.get("started_at"), + "bruger_navn": last_call_row.get("full_name") or last_call_row.get("username"), + "duration_sec": last_call_row.get("duration_sec"), + } + + return { + "recent_cases": [ + { + "id": case["id"], + "titel": case["titel"], + "created_at": case["created_at"], + } + for case in cases + ], + "last_call": last_call_data, + } diff --git a/app/opportunities/backend/router.py b/app/opportunities/backend/router.py index 8dff15e..f58d5ed 100644 --- a/app/opportunities/backend/router.py +++ b/app/opportunities/backend/router.py @@ -39,18 +39,28 @@ async def list_opportunities( s.customer_id, COALESCE(c.name, 'Ukendt kunde') as customer_name, s.ansvarlig_bruger_id, - COALESCE(u.first_name || ' ' || COALESCE(u.last_name, ''), 'Ingen') as ansvarlig_navn + COALESCE(u.full_name, u.username, 'Ingen') as ansvarlig_navn FROM sag_sager s LEFT JOIN customers c ON s.customer_id = c.id - LEFT JOIN users u ON s.ansvarlig_bruger_id = u.id + LEFT JOIN users u ON s.ansvarlig_bruger_id = u.user_id LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id WHERE s.deleted_at IS NULL AND ( s.template_key = 'pipeline' OR EXISTS ( - SELECT 1 FROM sag_tags st - JOIN tags t ON st.tag_id = t.id - WHERE st.sag_id = s.id AND t.name = 'pipeline' + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'case' + AND et.entity_id = s.id + AND LOWER(t.name) = 'pipeline' + ) + OR EXISTS ( + SELECT 1 + FROM sag_tags st + WHERE st.sag_id = s.id + AND st.deleted_at IS NULL + AND LOWER(st.tag_navn) = 'pipeline' ) ) """ @@ -136,12 +146,26 @@ async def create_opportunity( @router.get("/pipeline/stages", tags=["Opportunities"]) async def list_pipeline_stages(): - """ - Legacy endpoint for stages. - Returns static stages mapped to Case statuses for compatibility. - """ + """List available pipeline stages from DB with a safe static fallback.""" + try: + stages = execute_query( + """ + SELECT id, name, color, sort_order + FROM pipeline_stages + WHERE COALESCE(is_active, TRUE) = TRUE + ORDER BY sort_order ASC, id ASC + """ + ) + if stages: + return stages + except Exception as e: + logger.warning("Could not load pipeline stages from DB: %s", e) + return [ - {"id": "open", "name": "Åben"}, - {"id": "won", "name": "Vundet"}, - {"id": "lost", "name": "Tabt"} + {"id": 1, "name": "Lead", "color": "#6c757d", "sort_order": 10}, + {"id": 2, "name": "Kontakt", "color": "#17a2b8", "sort_order": 20}, + {"id": 3, "name": "Tilbud", "color": "#ffc107", "sort_order": 30}, + {"id": 4, "name": "Forhandling", "color": "#fd7e14", "sort_order": 40}, + {"id": 5, "name": "Vundet", "color": "#28a745", "sort_order": 50}, + {"id": 6, "name": "Tabt", "color": "#dc3545", "sort_order": 60}, ] diff --git a/app/products/backend/router.py b/app/products/backend/router.py index 0441b1d..2020f13 100644 --- a/app/products/backend/router.py +++ b/app/products/backend/router.py @@ -10,6 +10,7 @@ import logging import os import aiohttp import json +import asyncio logger = logging.getLogger(__name__) router = APIRouter() @@ -22,6 +23,57 @@ def _apigw_headers() -> Dict[str, str]: return {"Authorization": f"Bearer {token}"} +def _apigw_base_url() -> str: + base_url = ( + settings.APIGW_BASE_URL + or settings.APIGATEWAY_URL + or os.getenv("APIGW_BASE_URL") + or os.getenv("APIGATEWAY_URL") + or "" + ).strip() + if not base_url: + raise HTTPException(status_code=500, detail="API Gateway base URL is not configured") + return base_url.rstrip("/") + + +def _extract_error_detail(payload: Any) -> str: + if payload is None: + return "" + + if isinstance(payload, str): + return payload.strip() + + if isinstance(payload, dict): + for key in ("detail", "message", "error", "description"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return json.dumps(payload, ensure_ascii=False) + + if isinstance(payload, list): + parts = [_extract_error_detail(item) for item in payload] + cleaned = [part for part in parts if part] + return "; ".join(cleaned) + + return str(payload).strip() + + +async def _read_apigw_error(response: aiohttp.ClientResponse) -> str: + try: + body = await response.json(content_type=None) + detail = _extract_error_detail(body) + if detail: + return detail + except Exception: + pass + + text = (await response.text() or "").strip() + if text: + return text + + return f"API Gateway request failed (HTTP {response.status})" + + def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]: supplier_name = payload.get("supplier_name") supplier_code = payload.get("supplier_code") @@ -157,7 +209,7 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens: supplier = str(product.get("supplier_name") or "") haystack = " ".join( - "".join(ch.lower() if ch.isalnum() else " " for ch in value).split() + " ".join("".join(ch.lower() if ch.isalnum() else " " for ch in value).split()) for value in (name, sku, manufacturer, category, supplier) if value ) @@ -220,8 +272,7 @@ async def search_apigw_products( if not params: raise HTTPException(status_code=400, detail="Provide at least one search parameter") - base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL - url = f"{base_url.rstrip('/')}/api/v1/products/search" + url = f"{_apigw_base_url()}/api/v1/products/search" logger.info("🔍 APIGW product search: %s", params) timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS) @@ -229,8 +280,11 @@ async def search_apigw_products( async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(url, headers=_apigw_headers(), params=params) as response: if response.status >= 400: - detail = await response.text() - raise HTTPException(status_code=response.status, detail=detail) + detail = await _read_apigw_error(response) + raise HTTPException( + status_code=502, + detail=f"API Gateway product search failed ({response.status}): {detail}", + ) data = await response.json() if q and isinstance(data, dict) and isinstance(data.get("products"), list): @@ -243,6 +297,12 @@ async def search_apigw_products( return data except HTTPException: raise + except asyncio.TimeoutError: + logger.error("❌ APIGW product search timeout for params: %s", params, exc_info=True) + raise HTTPException(status_code=504, detail="API Gateway product search timed out") + except aiohttp.ClientError as e: + logger.error("❌ APIGW product search connection error: %s", e, exc_info=True) + raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}") except Exception as e: logger.error("❌ Error searching APIGW products: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/products/frontend/list.html b/app/products/frontend/list.html index e5ed07d..df63ce6 100644 --- a/app/products/frontend/list.html +++ b/app/products/frontend/list.html @@ -348,7 +348,14 @@
- + +
Vaelg produkttype for korrekt kategorisering.
@@ -359,15 +366,25 @@
- + +
+
+ +
- +
+ + DKK +
- - + +
+ + DKK +
@@ -685,6 +702,7 @@ async function createProduct() { type: document.getElementById('productType').value.trim() || null, status: document.getElementById('productStatus').value, sku_internal: document.getElementById('productSku').value.trim() || null, + ean: document.getElementById('productEan').value.trim() || null, sales_price: document.getElementById('productSalesPrice').value || null, cost_price: document.getElementById('productCostPrice').value || null, vat_rate: document.getElementById('productVatRate').value || null, diff --git a/app/services/simplycrm_service.py b/app/services/simplycrm_service.py index f241c48..6733790 100644 --- a/app/services/simplycrm_service.py +++ b/app/services/simplycrm_service.py @@ -29,6 +29,8 @@ class SimplyCRMService: self.session_name: Optional[str] = None self.session: Optional[aiohttp.ClientSession] = None + self.last_query_error: Optional[Dict[str, Any]] = None + self._denied_relation_fields: set[str] = set() if not all([self.base_url, self.username, self.access_key]): logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)") @@ -169,14 +171,20 @@ class SimplyCRMService: data = await response.json() if not data.get("success"): error = data.get("error", {}) - logger.error(f"❌ Simply-CRM query error: {error}") + self.last_query_error = error if isinstance(error, dict) else {"message": str(error)} + if (self.last_query_error or {}).get("code") == "ACCESS_DENIED": + logger.warning(f"⚠️ Simply-CRM query access denied: {error}") + else: + logger.error(f"❌ Simply-CRM query error: {error}") return [] result = data.get("result", []) + self.last_query_error = None logger.debug(f"✅ Simply-CRM query returned {len(result)} records") return result except Exception as e: + self.last_query_error = {"message": str(e)} logger.error(f"❌ Simply-CRM query error: {e}") return [] @@ -224,8 +232,26 @@ class SimplyCRMService: """ module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments") relation_field = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD", "related_to") - query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}' ORDER BY createdtime ASC;" - return await self.query(query) + relation_candidates: List[str] = [] + for candidate in [relation_field, "parent_id", "ticket_id", "crmid", "related_to"]: + if candidate and candidate not in relation_candidates: + relation_candidates.append(candidate) + + for candidate in relation_candidates: + if candidate in self._denied_relation_fields: + continue + + query = f"SELECT * FROM {module_name} WHERE {candidate} = '{ticket_id}' ORDER BY createdtime ASC;" + result = await self.query(query) + error_code = (self.last_query_error or {}).get("code") + + if error_code == "ACCESS_DENIED": + self._denied_relation_fields.add(candidate) + continue + + return result + + return [] async def fetch_ticket_emails(self, ticket_id: str) -> List[Dict]: """ @@ -242,12 +268,26 @@ class SimplyCRMService: fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to") records: List[Dict] = [] - query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}';" - records.extend(await self.query(query)) + relation_candidates: List[str] = [] + for candidate in [relation_field, fallback_relation_field, "parent_id", "ticket_id", "crmid"]: + if candidate and candidate not in relation_candidates: + relation_candidates.append(candidate) - if not records and fallback_relation_field: - query = f"SELECT * FROM {module_name} WHERE {fallback_relation_field} = '{ticket_id}';" - records.extend(await self.query(query)) + for candidate in relation_candidates: + if candidate in self._denied_relation_fields: + continue + + query = f"SELECT * FROM {module_name} WHERE {candidate} = '{ticket_id}';" + batch = await self.query(query) + error_code = (self.last_query_error or {}).get("code") + + if error_code == "ACCESS_DENIED": + self._denied_relation_fields.add(candidate) + continue + + records.extend(batch) + if records: + break # De-duplicate by record id if present seen_ids = set() diff --git a/app/services/vtiger_service.py b/app/services/vtiger_service.py index 5827ed2..a5c2146 100644 --- a/app/services/vtiger_service.py +++ b/app/services/vtiger_service.py @@ -27,6 +27,9 @@ class VTigerService: if not all([self.base_url, self.username, self.api_key]): logger.warning("⚠️ vTiger credentials not fully configured") + + self.last_query_status: Optional[int] = None + self.last_query_error: Optional[Dict] = None def _get_auth(self): """Get HTTP Basic Auth credentials""" @@ -49,6 +52,8 @@ class VTigerService: try: auth = self._get_auth() + self.last_query_status = None + self.last_query_error = None async with aiohttp.ClientSession() as session: async with session.get( f"{self.rest_endpoint}/query", @@ -56,6 +61,7 @@ class VTigerService: auth=auth ) as response: text = await response.text() + self.last_query_status = response.status if response.status == 200: # vTiger returns text/json instead of application/json @@ -69,16 +75,28 @@ class VTigerService: if data.get('success'): result = data.get('result', []) logger.info(f"✅ Query returned {len(result)} records") + self.last_query_error = None return result else: - logger.error(f"❌ vTiger query failed: {data.get('error')}") + self.last_query_error = data.get('error') + logger.error(f"❌ vTiger query failed: {self.last_query_error}") return [] else: - logger.error(f"❌ vTiger query HTTP error {response.status}") + try: + parsed = json.loads(text) if text else {} + self.last_query_error = parsed.get('error') + except Exception: + self.last_query_error = None + if response.status == 429: + logger.warning(f"⚠️ vTiger query rate-limited (HTTP {response.status})") + else: + logger.error(f"❌ vTiger query HTTP error {response.status}") logger.error(f"Query: {query_string}") logger.error(f"Response: {text[:500]}") return [] except Exception as e: + self.last_query_status = None + self.last_query_error = {"message": str(e)} logger.error(f"❌ vTiger query error: {e}") return [] diff --git a/app/settings/backend/views.py b/app/settings/backend/views.py index 45bc137..d1365e4 100644 --- a/app/settings/backend/views.py +++ b/app/settings/backend/views.py @@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates from pydantic import BaseModel from app.core.config import settings -from app.core.database import get_db_connection, release_db_connection +from app.core.database import get_db_connection, release_db_connection, execute_query_single router = APIRouter() templates = Jinja2Templates(directory="app") @@ -19,9 +19,27 @@ templates = Jinja2Templates(directory="app") @router.get("/settings", response_class=HTMLResponse, tags=["Frontend"]) async def settings_page(request: Request): """Render settings page""" + default_dashboard_path = "" + user_id = getattr(request.state, "user_id", None) + + if user_id: + try: + row = execute_query_single( + """ + SELECT default_dashboard_path + FROM user_dashboard_preferences + WHERE user_id = %s + """, + (int(user_id),) + ) + default_dashboard_path = (row or {}).get("default_dashboard_path") or "" + except Exception: + default_dashboard_path = "" + return templates.TemplateResponse("settings/frontend/settings.html", { "request": request, - "title": "Indstillinger" + "title": "Indstillinger", + "default_dashboard_path": default_dashboard_path }) diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index e43ed57..09cf42b 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -1020,6 +1020,40 @@ async def scan_document(file_path: str):
+
+
Standard Dashboard
+

Dashboard vises altid fra roden af sitet via /. Vælg her hvilken side der skal åbnes som dit standard-dashboard.

+ +
+
+ + +
Vælg et gyldigt dashboard fra listen.
+
+
+ + +
+ + +
+
+ + + +
+
+
System Indstillinger
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 0b4a28e..6a13f5a 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -261,7 +261,7 @@
Dato BeskrivelseSag Kunde Antal Enhed
${item.line_date || '-'}${item.description || '-'}${caseLink}${item.customer_name || '-'}${item.quantity ?? '-'}${item.unit || '-'}${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}${formatCurrency(item.amount)}${statusLabel}
+ + +
+
${caseLink}
+
+ ${group.items.length} ${group.items.length === 1 ? 'linje' : 'linjer'} + ${formatCurrency(groupTotal)} +
+
+
${item.line_date || '-'}${item.description || '-'}${item.customer_name || '-'}${item.quantity ?? '-'}${item.unit || '-'}${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}${formatCurrency(item.amount)}${statusLabel}
+ + + + + + + + + + + +
KundeRækkerMapped
Indlæser...
+
+
+
+
+
Valgt kunde: Ingen
+ +
+
+ + + + + + + + + + + + + + + +
AbonnementBeløbMap kundeSag (valgfri)Status
Vælg en kunde fra køen
+
+
+
+
+
+ +
+
+
+
Alle importerede rækker
+ Viser seneste importerede subscriptions (maks 500) +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + +
IDSO#Kunde (Simply)Hub kundeSagProduktBeløbStatusOpdateret
Indlæser...
+
+
+
+
+ + +{% endblock %} diff --git a/app/subscriptions/frontend/views.py b/app/subscriptions/frontend/views.py index 59e39f2..a9083d1 100644 --- a/app/subscriptions/frontend/views.py +++ b/app/subscriptions/frontend/views.py @@ -17,3 +17,11 @@ async def subscriptions_list(request: Request): return templates.TemplateResponse("subscriptions/frontend/list.html", { "request": request }) + + +@router.get("/subscriptions/simply-imports", response_class=HTMLResponse) +async def subscriptions_simply_imports(request: Request): + """Dedicated page for Simply subscription import parking/staging overview.""" + return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", { + "request": request + }) diff --git a/app/ticket/backend/router.py b/app/ticket/backend/router.py index f134e7f..12f496c 100644 --- a/app/ticket/backend/router.py +++ b/app/ticket/backend/router.py @@ -9,12 +9,14 @@ import logging import hashlib import json import re +import asyncio from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, status from fastapi.responses import JSONResponse from app.ticket.backend.ticket_service import TicketService from app.services.simplycrm_service import SimplyCRMService +from app.services.vtiger_service import get_vtiger_service from app.core.config import settings from app.ticket.backend.economic_export import ticket_economic_service from app.ticket.backend.models import ( @@ -125,6 +127,24 @@ def _escape_simply_value(value: str) -> str: return value.replace("'", "''") +async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5, base_delay: float = 1.25) -> List[dict]: + """Run vTiger query with exponential backoff on rate-limit responses.""" + for attempt in range(retries + 1): + await asyncio.sleep(0.15) + result = await vtiger.query(query_string) + status_code = getattr(vtiger, "last_query_status", None) + error = getattr(vtiger, "last_query_error", None) or {} + error_code = error.get("code") if isinstance(error, dict) else None + + if status_code != 429 and error_code != "TOO_MANY_REQUESTS": + return result + + if attempt < retries: + await asyncio.sleep(base_delay * (2 ** attempt)) + + return [] + + # ============================================================================ # TICKET ENDPOINTS # ============================================================================ @@ -1810,7 +1830,7 @@ async def import_simply_archived_tickets( """ One-time import of archived tickets from Simply-CRM. """ - stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} + stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0} try: async with SimplyCRMService() as service: @@ -1854,6 +1874,10 @@ async def import_simply_archived_tickets( ticket, ["title", "subject", "ticket_title", "tickettitle", "summary"] ) + contact_name = _get_first_value( + ticket, + ["contactname", "contact_name", "contact"] + ) organization_name = _get_first_value( ticket, ["accountname", "account_name", "organization", "company"] @@ -1958,50 +1982,51 @@ async def import_simply_archived_tickets( ) if existing: - if not force and existing.get("sync_hash") == data_hash: + hash_matches = existing.get("sync_hash") == data_hash + if not force and hash_matches: + archived_ticket_id = existing["id"] stats["skipped"] += 1 - continue - - execute_update( - """ - UPDATE tticket_archived_tickets - SET ticket_number = %s, - title = %s, - organization_name = %s, - contact_name = %s, - email_from = %s, - time_spent_hours = %s, - description = %s, - solution = %s, - status = %s, - priority = %s, - source_created_at = %s, - source_updated_at = %s, - last_synced_at = CURRENT_TIMESTAMP, - sync_hash = %s, - raw_data = %s::jsonb - WHERE id = %s - """, - ( - ticket_number, - title, - organization_name, - contact_name, - email_from, - time_spent_hours, - description, - solution, - status, - priority, - source_created_at, - source_updated_at, - data_hash, - json.dumps(ticket, default=str), - existing["id"] + else: + execute_update( + """ + UPDATE tticket_archived_tickets + SET ticket_number = %s, + title = %s, + organization_name = %s, + contact_name = %s, + email_from = %s, + time_spent_hours = %s, + description = %s, + solution = %s, + status = %s, + priority = %s, + source_created_at = %s, + source_updated_at = %s, + last_synced_at = CURRENT_TIMESTAMP, + sync_hash = %s, + raw_data = %s::jsonb + WHERE id = %s + """, + ( + ticket_number, + title, + organization_name, + contact_name, + email_from, + time_spent_hours, + description, + solution, + status, + priority, + source_created_at, + source_updated_at, + data_hash, + json.dumps(ticket, default=str), + existing["id"] + ) ) - ) - archived_ticket_id = existing["id"] - stats["updated"] += 1 + archived_ticket_id = existing["id"] + stats["updated"] += 1 else: archived_ticket_id = execute_insert( """ @@ -2085,6 +2110,7 @@ async def import_simply_archived_tickets( json.dumps(comment, default=str) ) ) + stats["messages_imported"] += 1 for email in emails: execute_insert( @@ -2112,6 +2138,7 @@ async def import_simply_archived_tickets( json.dumps(email, default=str) ) ) + stats["messages_imported"] += 1 except Exception as e: logger.error(f"❌ Archived ticket import failed: {e}") @@ -2125,6 +2152,347 @@ async def import_simply_archived_tickets( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/archived/vtiger/import", tags=["Archived Tickets"]) +async def import_vtiger_archived_tickets( + limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"), + include_messages: bool = Query(True, description="Include comments and emails"), + ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"), + force: bool = Query(False, description="Update even if sync hash matches") +): + """ + One-time import of archived tickets from vTiger (Cases module). + """ + stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0} + + try: + vtiger = get_vtiger_service() + + if ticket_number: + sanitized = _escape_simply_value(ticket_number) + tickets = [] + for field in ("ticket_no", "ticketnumber", "ticket_number"): + query = f"SELECT * FROM Cases WHERE {field} = '{sanitized}' LIMIT 1;" + tickets = await _vtiger_query_with_retry(vtiger, query) + if tickets: + break + else: + tickets = [] + offset = 0 + batch_size = 200 + while len(tickets) < limit: + query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};" + batch = await _vtiger_query_with_retry(vtiger, query) + if not batch: + break + tickets.extend(batch) + offset += batch_size + if len(batch) < batch_size: + break + + tickets = tickets[:limit] + + logger.info(f"🔍 Importing {len(tickets)} archived tickets from vTiger") + + account_cache: dict[str, Optional[str]] = {} + contact_cache: dict[str, Optional[str]] = {} + contact_account_cache: dict[str, Optional[str]] = {} + + for ticket in tickets: + try: + external_id = _get_first_value(ticket, ["id", "ticketid", "ticket_id"]) + if not external_id: + stats["skipped"] += 1 + continue + + data_hash = _calculate_hash(ticket) + existing = execute_query_single( + """SELECT id, sync_hash + FROM tticket_archived_tickets + WHERE source_system = %s AND external_id = %s""", + ("vtiger", external_id) + ) + + ticket_no_value = _get_first_value( + ticket, + ["ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber", "id"] + ) + title = _get_first_value( + ticket, + ["title", "subject", "ticket_title", "tickettitle", "summary"] + ) + contact_name = _get_first_value( + ticket, + ["contactname", "contact_name", "contact", "firstname", "lastname"] + ) + organization_name = _get_first_value( + ticket, + ["accountname", "account_name", "organization", "company"] + ) + + account_id = _get_first_value( + ticket, + ["parent_id", "account_id", "accountid", "account"] + ) + if not organization_name and _looks_like_external_id(account_id): + if account_id not in account_cache: + account_rows = await _vtiger_query_with_retry( + vtiger, + f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;" + ) + account_cache[account_id] = _get_first_value( + account_rows[0] if account_rows else {}, + ["accountname", "account_name", "name"] + ) + organization_name = account_cache.get(account_id) + + contact_id = _get_first_value( + ticket, + ["contact_id", "contactid"] + ) + if _looks_like_external_id(contact_id): + if contact_id not in contact_cache or contact_id not in contact_account_cache: + contact_rows = await _vtiger_query_with_retry( + vtiger, + f"SELECT * FROM Contacts WHERE id='{contact_id}' LIMIT 1;" + ) + contact_data = contact_rows[0] if contact_rows else {} + first_name = _get_first_value(contact_data, ["firstname", "first_name", "first"]) + last_name = _get_first_value(contact_data, ["lastname", "last_name", "last"]) + combined_name = " ".join([name for name in [first_name, last_name] if name]).strip() + contact_cache[contact_id] = combined_name or _get_first_value( + contact_data, + ["contactname", "contact_name", "name"] + ) + related_account_id = _get_first_value( + contact_data, + ["account_id", "accountid", "account", "parent_id"] + ) + contact_account_cache[contact_id] = related_account_id if _looks_like_external_id(related_account_id) else None + + if not contact_name: + contact_name = contact_cache.get(contact_id) + + if not organization_name: + related_account_id = contact_account_cache.get(contact_id) + if related_account_id: + if related_account_id not in account_cache: + account_rows = await _vtiger_query_with_retry( + vtiger, + f"SELECT * FROM Accounts WHERE id='{related_account_id}' LIMIT 1;" + ) + account_cache[related_account_id] = _get_first_value( + account_rows[0] if account_rows else {}, + ["accountname", "account_name", "name"] + ) + organization_name = account_cache.get(related_account_id) + + email_from = _get_first_value( + ticket, + ["email_from", "from_email", "from", "email", "email_from_address"] + ) + time_spent_hours = _parse_hours( + _get_first_value(ticket, ["time_spent", "hours", "time_spent_hours", "spent_time", "cf_time_spent", "cf_tid_brugt"]) + ) + description = _get_first_value( + ticket, + ["description", "ticket_description", "comments", "issue"] + ) + solution = _get_first_value( + ticket, + ["solution", "resolution", "solutiontext", "resolution_text", "answer"] + ) + status = _get_first_value( + ticket, + ["status", "casestatus", "ticketstatus", "state"] + ) + priority = _get_first_value( + ticket, + ["priority", "ticketpriorities", "ticketpriority"] + ) + source_created_at = _parse_datetime( + _get_first_value(ticket, ["createdtime", "created_at", "createdon", "created_time"]) + ) + source_updated_at = _parse_datetime( + _get_first_value(ticket, ["modifiedtime", "updated_at", "modified_time", "updatedtime"]) + ) + + if existing: + hash_matches = existing.get("sync_hash") == data_hash + if not force and hash_matches: + archived_ticket_id = existing["id"] + stats["skipped"] += 1 + else: + execute_update( + """ + UPDATE tticket_archived_tickets + SET ticket_number = %s, + title = %s, + organization_name = %s, + contact_name = %s, + email_from = %s, + time_spent_hours = %s, + description = %s, + solution = %s, + status = %s, + priority = %s, + source_created_at = %s, + source_updated_at = %s, + last_synced_at = CURRENT_TIMESTAMP, + sync_hash = %s, + raw_data = %s::jsonb + WHERE id = %s + """, + ( + ticket_no_value, + title, + organization_name, + contact_name, + email_from, + time_spent_hours, + description, + solution, + status, + priority, + source_created_at, + source_updated_at, + data_hash, + json.dumps(ticket, default=str), + existing["id"] + ) + ) + archived_ticket_id = existing["id"] + stats["updated"] += 1 + else: + archived_ticket_id = execute_insert( + """ + INSERT INTO tticket_archived_tickets ( + source_system, + external_id, + ticket_number, + title, + organization_name, + contact_name, + email_from, + time_spent_hours, + description, + solution, + status, + priority, + source_created_at, + source_updated_at, + last_synced_at, + sync_hash, + raw_data + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + CURRENT_TIMESTAMP, %s, %s::jsonb + ) + RETURNING id + """, + ( + "vtiger", + external_id, + ticket_no_value, + title, + organization_name, + contact_name, + email_from, + time_spent_hours, + description, + solution, + status, + priority, + source_created_at, + source_updated_at, + data_hash, + json.dumps(ticket, default=str) + ) + ) + stats["imported"] += 1 + + if include_messages and archived_ticket_id: + execute_update( + "DELETE FROM tticket_archived_messages WHERE archived_ticket_id = %s", + (archived_ticket_id,) + ) + + comments = await _vtiger_query_with_retry( + vtiger, + f"SELECT * FROM ModComments WHERE related_to = '{external_id}';" + ) + emails = await _vtiger_query_with_retry( + vtiger, + f"SELECT * FROM Emails WHERE parent_id = '{external_id}';" + ) + + for comment in comments: + execute_insert( + """ + INSERT INTO tticket_archived_messages ( + archived_ticket_id, + message_type, + subject, + body, + author_name, + author_email, + source_created_at, + raw_data + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) + RETURNING id + """, + ( + archived_ticket_id, + "comment", + None, + _get_first_value(comment, ["commentcontent", "comment", "content", "description"]), + _get_first_value(comment, ["author", "assigned_user_id", "created_by", "creator"]), + _get_first_value(comment, ["email", "author_email", "from_email"]), + _parse_datetime(_get_first_value(comment, ["createdtime", "created_at", "created_time"])), + json.dumps(comment, default=str) + ) + ) + stats["messages_imported"] += 1 + + for email in emails: + execute_insert( + """ + INSERT INTO tticket_archived_messages ( + archived_ticket_id, + message_type, + subject, + body, + author_name, + author_email, + source_created_at, + raw_data + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) + RETURNING id + """, + ( + archived_ticket_id, + "email", + _get_first_value(email, ["subject", "title"]), + _get_first_value(email, ["description", "body", "email_body", "content"]), + _get_first_value(email, ["from_name", "sender", "assigned_user_id"]), + _get_first_value(email, ["from_email", "email", "sender_email"]), + _parse_datetime(_get_first_value(email, ["createdtime", "created_at", "created_time"])), + json.dumps(email, default=str) + ) + ) + stats["messages_imported"] += 1 + + except Exception as e: + logger.error(f"❌ vTiger archived ticket import failed: {e}") + stats["errors"] += 1 + + logger.info(f"✅ vTiger archived ticket import complete: {stats}") + return stats + + except Exception as e: + logger.error(f"❌ vTiger archived ticket import failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/archived/simply/modules", tags=["Archived Tickets"]) async def list_simply_modules(): """ diff --git a/app/ticket/frontend/archived_ticket_list.html b/app/ticket/frontend/archived_ticket_list.html index b3e18fc..090c0c4 100644 --- a/app/ticket/frontend/archived_ticket_list.html +++ b/app/ticket/frontend/archived_ticket_list.html @@ -143,7 +143,7 @@ name="search" id="search" class="form-control" - placeholder="Ticket nr, titel eller beskrivelse..." + placeholder="Ticket nr, titel, løsning eller kommentar..." value="{{ search_query or '' }}"> @@ -209,6 +209,8 @@ Organisation Kontakt Email From + Løsning + Kommentarer Tid brugt Status Oprettet @@ -227,6 +229,18 @@ {{ ticket.organization_name or '-' }} {{ ticket.contact_name or '-' }} {{ ticket.email_from or '-' }} + + {% if ticket.solution and ticket.solution.strip() %} + {{ ticket.solution[:120] }}{% if ticket.solution|length > 120 %}...{% endif %} + {% else %} + - + {% endif %} + + + + {{ ticket.message_count or 0 }} + + {% if ticket.time_spent_hours is not none %} {{ '%.2f'|format(ticket.time_spent_hours) }} t diff --git a/app/ticket/frontend/dashboard.html b/app/ticket/frontend/dashboard.html index 7c933d6..2b03273 100644 --- a/app/ticket/frontend/dashboard.html +++ b/app/ticket/frontend/dashboard.html @@ -11,6 +11,9 @@

Oversigt over alle support tickets og aktivitet

+ diff --git a/app/ticket/frontend/mockups/tech_v1_overview.html b/app/ticket/frontend/mockups/tech_v1_overview.html new file mode 100644 index 0000000..468d503 --- /dev/null +++ b/app/ticket/frontend/mockups/tech_v1_overview.html @@ -0,0 +1,120 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %} + +{% block content %} +
+
+
+

🛠️ Tekniker Dashboard V1

+

Kort overblik for {{ technician_name }} (bruger #{{ technician_user_id }})

+
+ +
+ +
+
Nye sager
{{ kpis.new_cases_count }}
+
Mine sager
{{ kpis.my_cases_count }}
+
Dagens opgaver
{{ kpis.today_tasks_count }}
+
Haste / over SLA
{{ kpis.urgent_overdue_count }}
+
Mine opportunities
{{ kpis.my_opportunities_count }}
+
+ +
+
+
+
Nye sager
+
+
+ + + + {% for item in new_cases %} + + + + + + + {% else %} + + {% endfor %} + +
IDTitelKundeOprettet
#{{ item.id }}{{ item.titel }}{{ item.customer_name }}{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}
Ingen nye sager
+
+
+
+
+ +
+
+
Mine sager
+
+
+ + + + {% for item in my_cases %} + + + + + + + {% else %} + + {% endfor %} + +
IDTitelDeadlineStatus
#{{ item.id }}{{ item.titel }}{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}{{ item.status }}
Ingen sager tildelt
+
+
+
+
+ +
+
+
Haste / over SLA
+
+ {% for item in urgent_overdue %} +
+
+
{{ item.title }}
+
{{ item.customer_name }} · {{ item.attention_reason }}
+
+ Åbn +
+ {% else %} +

Ingen haste-emner lige nu.

+ {% endfor %} +
+
+
+ +
+
+
Mine opportunities
+
+ {% for item in my_opportunities %} +
+
+
{{ item.titel }}
+
{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}
+
+
+
{{ "%.0f"|format(item.pipeline_probability or 0) }}%
+ Åbn +
+
+ {% else %} +

Ingen opportunities fundet.

+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/ticket/frontend/mockups/tech_v2_workboard.html b/app/ticket/frontend/mockups/tech_v2_workboard.html new file mode 100644 index 0000000..2726cc2 --- /dev/null +++ b/app/ticket/frontend/mockups/tech_v2_workboard.html @@ -0,0 +1,127 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Tekniker Dashboard V2 - Workboard{% endblock %} + +{% block content %} +
+
+
+

🛠️ Tekniker Dashboard V2

+

Workboard-visning for {{ technician_name }} (bruger #{{ technician_user_id }})

+
+ +
+ +
+
+
+
+
Dagens opgaver
+ {{ kpis.today_tasks_count }} +
+
+ {% for item in today_tasks %} +
+
{{ item.title }}
+
{{ item.customer_name }} · {{ item.task_reason }}
+ Åbn +
+ {% else %} +

Ingen opgaver i dag.

+ {% endfor %} +
+
+
+ +
+
+
+
Mine sager
+ {{ kpis.my_cases_count }} +
+
+ {% for item in my_cases %} +
+
#{{ item.id }} · {{ item.titel }}
+
{{ item.customer_name }} · Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}
+ Åbn +
+ {% else %} +

Ingen aktive sager.

+ {% endfor %} +
+
+
+ +
+
+
+
Haste / over SLA
+ {{ kpis.urgent_overdue_count }} +
+
+ {% for item in urgent_overdue %} +
+
{{ item.title }}
+
{{ item.customer_name }} · {{ item.attention_reason }}
+ Åbn +
+ {% else %} +

Ingen kritiske emner.

+ {% endfor %} +
+
+
+ +
+
+
+
Nye sager
+ {{ kpis.new_cases_count }} +
+
+
+ + + + {% for item in new_cases %} + + + + {% else %} + + {% endfor %} + +
IDTitelKundeOprettet
#{{ item.id }}{{ item.titel }}{{ item.customer_name }}{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}
Ingen nye sager
+
+
+
+
+ +
+
+
+
Mine opportunities
+ {{ kpis.my_opportunities_count }} +
+
+ {% for item in my_opportunities %} +
+
{{ item.titel }}
+
{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}
+
Sandsynlighed: {{ "%.0f"|format(item.pipeline_probability or 0) }}%
+ Åbn +
+ {% else %} +

Ingen opportunities.

+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/ticket/frontend/mockups/tech_v3_table_focus.html b/app/ticket/frontend/mockups/tech_v3_table_focus.html new file mode 100644 index 0000000..3b3885e --- /dev/null +++ b/app/ticket/frontend/mockups/tech_v3_table_focus.html @@ -0,0 +1,124 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Tekniker Dashboard V3 - Power Table{% endblock %} + +{% block content %} +
+
+
+

🛠️ Tekniker Dashboard V3

+

Power table for {{ technician_name }} (bruger #{{ technician_user_id }})

+
+ +
+ +
+
+
+
+
Samlet teknikeroverblik
+
+ Nye: {{ kpis.new_cases_count }} + Mine: {{ kpis.my_cases_count }} + Haste: {{ kpis.urgent_overdue_count }} +
+
+
+
+ + + + + + + + + + + + + + + {% for item in urgent_overdue %} + + + + + + + + + + + {% endfor %} + + {% for item in today_tasks %} + + + + + + + + + + + {% endfor %} + + {% for item in my_cases %} + + + + + + + + + + + {% endfor %} + + {% if not urgent_overdue and not today_tasks and not my_cases %} + + + + {% endif %} + +
TypeIDTitelKundeStatusPrioritet/ReasonDeadlineHandling
Haste#{{ item.item_id }}{{ item.title }}{{ item.customer_name }}{{ item.status }}{{ item.attention_reason }}{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}Åbn
I dag#{{ item.item_id }}{{ item.title }}{{ item.customer_name }}{{ item.status }}{{ item.task_reason }}{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}Åbn
Min sag#{{ item.id }}{{ item.titel }}{{ item.customer_name }}{{ item.status }}-{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}Åbn
Ingen data at vise for denne tekniker.
+
+
+
+
+ +
+
+
Nye sager
+
+ {% for item in new_cases[:6] %} +
#{{ item.id }} · {{ item.titel }} ({{ item.customer_name }})
+ {% else %} +
Ingen nye sager.
+ {% endfor %} +
+
+
+ +
+
+
Mine opportunities
+
+ {% for item in my_opportunities[:6] %} +
#{{ item.id }} · {{ item.titel }} ({{ item.pipeline_stage or 'Uden stage' }}, {{ "%.0f"|format(item.pipeline_probability or 0) }}%)
+ {% else %} +
Ingen opportunities.
+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/ticket/frontend/technician_dashboard_selector.html b/app/ticket/frontend/technician_dashboard_selector.html new file mode 100644 index 0000000..6ed1485 --- /dev/null +++ b/app/ticket/frontend/technician_dashboard_selector.html @@ -0,0 +1,107 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Tekniker Dashboard - Vælg Variant{% endblock %} + +{% block content %} +
+
+
+

🛠️ Tekniker Dashboard

+

Vælg den visning der passer bedst til {{ technician_name }} (bruger #{{ technician_user_id }})

+
+
+ + + +
+
+ +
+
+
+
+
Nye sager
+
{{ kpis.new_cases_count }}
+
+
+
+
+
+
+
Mine sager
+
{{ kpis.my_cases_count }}
+
+
+
+
+
+
+
Dagens opgaver
+
{{ kpis.today_tasks_count }}
+
+
+
+
+
+
+
Haste / over SLA
+
{{ kpis.urgent_overdue_count }}
+
+
+
+
+
+
+
Mine opportunities
+
{{ kpis.my_opportunities_count }}
+
+
+
+
+ +
+
+
+
+
Version 1: Overblik
+

KPI-kort + kompakte lister. God til hurtig prioritering.

+
    +
  • Fokus på status og antal
  • +
  • Hurtig scanning af nye/mine sager
  • +
  • Minimal støj
  • +
+ Åbn Version 1 +
+
+
+
+
+
+
Version 2: Workboard
+

3 kolonner med arbejdsflow. God til daglig drift.

+
    +
  • Visuel opdeling af arbejdsområder
  • +
  • Haste-emner centralt
  • +
  • God til standups
  • +
+ Åbn Version 2 +
+
+
+
+
+
+
Version 3: Power Table
+

Tabel-fokuseret dashboard. God til hurtig sortering og detaljevisning.

+
    +
  • Høj datatæthed
  • +
  • Nemt at sammenligne felter
  • +
  • Målrettet erfarne brugere
  • +
+ Åbn Version 3 +
+
+
+
+
+{% endblock %} diff --git a/app/ticket/frontend/views.py b/app/ticket/frontend/views.py index 168fbb3..b104c89 100644 --- a/app/ticket/frontend/views.py +++ b/app/ticket/frontend/views.py @@ -7,7 +7,7 @@ import logging from fastapi import APIRouter, Request, HTTPException, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from typing import Optional +from typing import Optional, Dict, Any from datetime import date from app.core.database import execute_query, execute_update, execute_query_single @@ -360,12 +360,309 @@ async def new_ticket_page(request: Request): return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request}) +def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]: + """Collect live data slices for technician-focused dashboard variants.""" + user_query = """ + SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name + FROM users + WHERE user_id = %s + LIMIT 1 + """ + user_result = execute_query(user_query, (technician_user_id,)) + technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}" + + new_cases_query = """ + SELECT + s.id, + s.titel, + s.status, + s.created_at, + s.deadline, + COALESCE(c.name, 'Ukendt kunde') AS customer_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND s.status = 'åben' + ORDER BY s.created_at DESC + LIMIT 12 + """ + new_cases = execute_query(new_cases_query) + + my_cases_query = """ + SELECT + s.id, + s.titel, + s.status, + s.created_at, + s.deadline, + COALESCE(c.name, 'Ukendt kunde') AS customer_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND s.ansvarlig_bruger_id = %s + AND s.status <> 'lukket' + ORDER BY s.created_at DESC + LIMIT 12 + """ + my_cases = execute_query(my_cases_query, (technician_user_id,)) + + today_tasks_query = """ + SELECT + 'case' AS item_type, + s.id AS item_id, + s.titel AS title, + s.status, + s.deadline AS due_at, + s.created_at, + COALESCE(c.name, 'Ukendt kunde') AS customer_name, + NULL::text AS priority, + 'Sag deadline i dag' AS task_reason + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND s.ansvarlig_bruger_id = %s + AND s.status <> 'lukket' + AND s.deadline = CURRENT_DATE + + UNION ALL + + SELECT + 'ticket' AS item_type, + t.id AS item_id, + t.subject AS title, + t.status, + NULL::date AS due_at, + t.created_at, + COALESCE(c.name, 'Ukendt kunde') AS customer_name, + COALESCE(t.priority, 'normal') AS priority, + 'Ticket oprettet i dag' AS task_reason + FROM tticket_tickets t + LEFT JOIN customers c ON c.id = t.customer_id + WHERE t.assigned_to_user_id = %s + AND t.status IN ('open', 'in_progress', 'pending_customer') + AND DATE(t.created_at) = CURRENT_DATE + + ORDER BY created_at DESC + LIMIT 12 + """ + today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id)) + + urgent_overdue_query = """ + SELECT + 'case' AS item_type, + s.id AS item_id, + s.titel AS title, + s.status, + s.deadline AS due_at, + s.created_at, + COALESCE(c.name, 'Ukendt kunde') AS customer_name, + NULL::text AS priority, + 'Over deadline' AS attention_reason + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND s.status <> 'lukket' + AND s.deadline IS NOT NULL + AND s.deadline < CURRENT_DATE + + UNION ALL + + SELECT + 'ticket' AS item_type, + t.id AS item_id, + t.subject AS title, + t.status, + NULL::date AS due_at, + t.created_at, + COALESCE(c.name, 'Ukendt kunde') AS customer_name, + COALESCE(t.priority, 'normal') AS priority, + CASE + WHEN t.priority = 'urgent' THEN 'Urgent prioritet' + ELSE 'Høj prioritet' + END AS attention_reason + FROM tticket_tickets t + LEFT JOIN customers c ON c.id = t.customer_id + WHERE t.status IN ('open', 'in_progress', 'pending_customer') + AND COALESCE(t.priority, '') IN ('urgent', 'high') + AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL) + + ORDER BY created_at DESC + LIMIT 15 + """ + urgent_overdue = execute_query(urgent_overdue_query, (technician_user_id,)) + + opportunities_query = """ + SELECT + s.id, + s.titel, + s.status, + s.pipeline_amount, + s.pipeline_probability, + ps.name AS pipeline_stage, + s.deadline, + s.created_at, + COALESCE(c.name, 'Ukendt kunde') AS customer_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id + WHERE s.deleted_at IS NULL + AND s.ansvarlig_bruger_id = %s + AND ( + s.template_key = 'pipeline' + OR EXISTS ( + SELECT 1 + FROM entity_tags et + JOIN tags t ON t.id = et.tag_id + WHERE et.entity_type = 'case' + AND et.entity_id = s.id + AND LOWER(t.name) = 'pipeline' + ) + OR EXISTS ( + SELECT 1 + FROM sag_tags st + WHERE st.sag_id = s.id + AND st.deleted_at IS NULL + AND LOWER(st.tag_navn) = 'pipeline' + ) + ) + ORDER BY s.created_at DESC + LIMIT 12 + """ + my_opportunities = execute_query(opportunities_query, (technician_user_id,)) + + return { + "technician_user_id": technician_user_id, + "technician_name": technician_name, + "new_cases": new_cases or [], + "my_cases": my_cases or [], + "today_tasks": today_tasks or [], + "urgent_overdue": urgent_overdue or [], + "my_opportunities": my_opportunities or [], + "kpis": { + "new_cases_count": len(new_cases or []), + "my_cases_count": len(my_cases or []), + "today_tasks_count": len(today_tasks or []), + "urgent_overdue_count": len(urgent_overdue or []), + "my_opportunities_count": len(my_opportunities or []) + } + } + + +def _is_user_in_technician_group(user_id: int) -> bool: + groups_query = """ + SELECT LOWER(g.name) AS group_name + FROM user_groups ug + JOIN groups g ON g.id = ug.group_id + WHERE ug.user_id = %s + """ + groups = execute_query(groups_query, (user_id,)) or [] + return any( + "teknik" in (g.get("group_name") or "") or "technician" in (g.get("group_name") or "") + for g in groups + ) + + +@router.get("/dashboard/technician", response_class=HTMLResponse) +async def technician_dashboard_selector( + request: Request, + technician_user_id: int = 1 +): + """Technician dashboard landing page with 3 variants to choose from.""" + try: + # Always use logged-in user, ignore query param + logged_in_user_id = getattr(request.state, "user_id", 1) + context = _get_technician_dashboard_data(logged_in_user_id) + return templates.TemplateResponse( + "ticket/frontend/technician_dashboard_selector.html", + { + "request": request, + **context + } + ) + except Exception as e: + logger.error(f"❌ Failed to load technician dashboard selector: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/dashboard/technician/v1", response_class=HTMLResponse) +async def technician_dashboard_v1( + request: Request, + technician_user_id: int = 1 +): + """Variant 1: KPI + card overview layout.""" + try: + # Always use logged-in user, ignore query param + logged_in_user_id = getattr(request.state, "user_id", 1) + context = _get_technician_dashboard_data(logged_in_user_id) + return templates.TemplateResponse( + "ticket/frontend/mockups/tech_v1_overview.html", + { + "request": request, + **context + } + ) + except Exception as e: + logger.error(f"❌ Failed to load technician dashboard v1: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/dashboard/technician/v2", response_class=HTMLResponse) +async def technician_dashboard_v2( + request: Request, + technician_user_id: int = 1 +): + """Variant 2: Workboard columns by focus areas.""" + try: + # Always use logged-in user, ignore query param + logged_in_user_id = getattr(request.state, "user_id", 1) + context = _get_technician_dashboard_data(logged_in_user_id) + return templates.TemplateResponse( + "ticket/frontend/mockups/tech_v2_workboard.html", + { + "request": request, + **context + } + ) + except Exception as e: + logger.error(f"❌ Failed to load technician dashboard v2: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/dashboard/technician/v3", response_class=HTMLResponse) +async def technician_dashboard_v3( + request: Request, + technician_user_id: int = 1 +): + """Variant 3: Dense table-first layout for power users.""" + try: + # Always use logged-in user, ignore query param + logged_in_user_id = getattr(request.state, "user_id", 1) + context = _get_technician_dashboard_data(logged_in_user_id) + return templates.TemplateResponse( + "ticket/frontend/mockups/tech_v3_table_focus.html", + { + "request": request, + **context + } + ) + except Exception as e: + logger.error(f"❌ Failed to load technician dashboard v3: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/dashboard", response_class=HTMLResponse) async def ticket_dashboard(request: Request): """ Ticket system dashboard with statistics """ try: + current_user_id = getattr(request.state, "user_id", None) + if current_user_id and _is_user_in_technician_group(int(current_user_id)): + return RedirectResponse( + url=f"/ticket/dashboard/technician/v1?technician_user_id={int(current_user_id)}", + status_code=302 + ) + # Get ticket statistics stats_query = """ SELECT @@ -530,34 +827,53 @@ async def archived_ticket_list_page( status, priority, source_created_at, - description - FROM tticket_archived_tickets + description, + solution, + ( + SELECT COUNT(*) + FROM tticket_archived_messages m + WHERE m.archived_ticket_id = t.id + ) AS message_count + FROM tticket_archived_tickets t WHERE 1=1 """ params = [] if search: - query += " AND (ticket_number ILIKE %s OR title ILIKE %s OR description ILIKE %s)" + query += """ + AND ( + t.ticket_number ILIKE %s + OR t.title ILIKE %s + OR t.description ILIKE %s + OR t.solution ILIKE %s + OR EXISTS ( + SELECT 1 + FROM tticket_archived_messages m + WHERE m.archived_ticket_id = t.id + AND m.body ILIKE %s + ) + ) + """ search_pattern = f"%{search}%" - params.extend([search_pattern] * 3) + params.extend([search_pattern] * 5) if organization: - query += " AND organization_name ILIKE %s" + query += " AND t.organization_name ILIKE %s" params.append(f"%{organization}%") if contact: - query += " AND contact_name ILIKE %s" + query += " AND t.contact_name ILIKE %s" params.append(f"%{contact}%") if date_from: - query += " AND source_created_at >= %s" + query += " AND t.source_created_at >= %s" params.append(date_from) if date_to: - query += " AND source_created_at <= %s" + query += " AND t.source_created_at <= %s" params.append(date_to) - query += " ORDER BY source_created_at DESC NULLS LAST, imported_at DESC LIMIT 200" + query += " ORDER BY t.source_created_at DESC NULLS LAST, t.imported_at DESC LIMIT 200" tickets = execute_query(query, tuple(params)) if params else execute_query(query) diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index 77d266c..a92cb24 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -45,6 +45,7 @@ from app.services.customer_consistency import CustomerConsistencyService from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService from app.services.vtiger_service import get_vtiger_service from app.ticket.backend.klippekort_service import KlippekortService +from app.core.auth_dependencies import get_optional_user logger = logging.getLogger(__name__) @@ -1773,7 +1774,10 @@ async def get_time_entries_for_sag(sag_id: int): raise HTTPException(status_code=500, detail="Failed to fetch time entries") @router.post("/entries/internal", tags=["Internal"]) -async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)): +async def create_internal_time_entry( + entry: Dict[str, Any] = Body(...), + current_user: Optional[dict] = Depends(get_optional_user) +): """ Create a time entry manually (Internal/Hub). Requires: sag_id, original_hours @@ -1786,7 +1790,12 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)): description = entry.get("description") hours = entry.get("original_hours") worked_date = entry.get("worked_date") or datetime.now().date() - user_name = entry.get("user_name", "Hub User") + default_user_name = ( + (current_user or {}).get("username") + or (current_user or {}).get("full_name") + or "Hub User" + ) + user_name = entry.get("user_name") or default_user_name prepaid_card_id = entry.get("prepaid_card_id") fixed_price_agreement_id = entry.get("fixed_price_agreement_id") work_type = entry.get("work_type", "support") diff --git a/compare_schemas.py b/compare_schemas.py index 5cee00a..ec9f661 100755 --- a/compare_schemas.py +++ b/compare_schemas.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 + #!/usr/bin/env python3 """ Compare local dev database schema with production to find missing columns """ diff --git a/main.py b/main.py index 8a6fd97..f0d536b 100644 --- a/main.py +++ b/main.py @@ -90,6 +90,8 @@ from app.modules.telefoni.backend import router as telefoni_api from app.modules.telefoni.frontend import views as telefoni_views from app.modules.calendar.backend import router as calendar_api from app.modules.calendar.frontend import views as calendar_views +from app.modules.orders.backend import router as orders_api +from app.modules.orders.frontend import views as orders_views # Configure logging logging.basicConfig( @@ -118,6 +120,7 @@ async def lifespan(app: FastAPI): # Register reminder scheduler job from app.jobs.check_reminders import check_reminders from apscheduler.triggers.interval import IntervalTrigger + from apscheduler.triggers.cron import CronTrigger backup_scheduler.scheduler.add_job( func=check_reminders, @@ -129,6 +132,19 @@ async def lifespan(app: FastAPI): ) logger.info("✅ Reminder job scheduled (every 5 minutes)") + # Register subscription invoice processing job + from app.jobs.process_subscriptions import process_subscriptions + + backup_scheduler.scheduler.add_job( + func=process_subscriptions, + trigger=CronTrigger(hour=4, minute=0), + id='process_subscriptions', + name='Process Subscription Invoices', + max_instances=1, + replace_existing=True + ) + logger.info("✅ Subscription invoice job scheduled (daily at 04:00)") + if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED: from app.jobs.eset_sync import run_eset_sync @@ -293,6 +309,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"]) app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"]) app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"]) app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"]) +app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"]) # Frontend Routers app.include_router(dashboard_views.router, tags=["Frontend"]) @@ -320,6 +337,7 @@ app.include_router(locations_views.router, tags=["Frontend"]) app.include_router(devportal_views.router, tags=["Frontend"]) app.include_router(telefoni_views.router, tags=["Frontend"]) app.include_router(calendar_views.router, tags=["Frontend"]) +app.include_router(orders_views.router, tags=["Frontend"]) # Serve static files (UI) app.mount("/static", StaticFiles(directory="static", html=True), name="static") diff --git a/migrations/130_user_dashboard_preferences.sql b/migrations/130_user_dashboard_preferences.sql new file mode 100644 index 0000000..c038caf --- /dev/null +++ b/migrations/130_user_dashboard_preferences.sql @@ -0,0 +1,11 @@ +-- Migration 130: User dashboard preferences +-- Stores per-user default dashboard path + +CREATE TABLE IF NOT EXISTS user_dashboard_preferences ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + default_dashboard_path VARCHAR(255) NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_dashboard_preferences_path + ON user_dashboard_preferences(default_dashboard_path); diff --git a/migrations/131_sag_assignment_group.sql b/migrations/131_sag_assignment_group.sql new file mode 100644 index 0000000..e665dfd --- /dev/null +++ b/migrations/131_sag_assignment_group.sql @@ -0,0 +1,7 @@ +-- Migration 131: Add group assignment to sager + +ALTER TABLE sag_sager + ADD COLUMN IF NOT EXISTS assigned_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_sag_sager_assigned_group_id + ON sag_sager(assigned_group_id); diff --git a/migrations/132_subscription_cancellation.sql b/migrations/132_subscription_cancellation.sql new file mode 100644 index 0000000..432327f --- /dev/null +++ b/migrations/132_subscription_cancellation.sql @@ -0,0 +1,17 @@ +-- Migration 132: Add cancellation rules to subscriptions +-- Adds fields for notice period, cancellation date, and termination order tracking + +ALTER TABLE sag_subscriptions +ADD COLUMN IF NOT EXISTS notice_period_days INTEGER DEFAULT 30 CHECK (notice_period_days >= 0), +ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS cancelled_by_user_id INTEGER REFERENCES users(user_id), +ADD COLUMN IF NOT EXISTS cancellation_sag_id INTEGER REFERENCES sag_sager(id), +ADD COLUMN IF NOT EXISTS cancellation_reason TEXT; + +CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_cancelled_at ON sag_subscriptions(cancelled_at); + +COMMENT ON COLUMN sag_subscriptions.notice_period_days IS 'Number of days notice required for cancellation (default 30)'; +COMMENT ON COLUMN sag_subscriptions.cancelled_at IS 'When the cancellation was requested'; +COMMENT ON COLUMN sag_subscriptions.cancelled_by_user_id IS 'User who requested cancellation'; +COMMENT ON COLUMN sag_subscriptions.cancellation_sag_id IS 'Case created for the cancellation process'; +COMMENT ON COLUMN sag_subscriptions.cancellation_reason IS 'Reason for cancellation'; diff --git a/migrations/133_ordre_drafts.sql b/migrations/133_ordre_drafts.sql new file mode 100644 index 0000000..77f3ffa --- /dev/null +++ b/migrations/133_ordre_drafts.sql @@ -0,0 +1,24 @@ +-- Migration 133: Ordre drafts persistence for global /ordre page + +CREATE TABLE IF NOT EXISTS ordre_drafts ( + id SERIAL PRIMARY KEY, + title VARCHAR(120) NOT NULL, + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + lines_json JSONB NOT NULL DEFAULT '[]'::jsonb, + notes TEXT, + layout_number INTEGER, + created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + export_status_json JSONB NOT NULL DEFAULT '{}'::jsonb, + last_exported_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ordre_drafts_user ON ordre_drafts(created_by_user_id); +CREATE INDEX IF NOT EXISTS idx_ordre_drafts_updated_at ON ordre_drafts(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_ordre_drafts_customer ON ordre_drafts(customer_id); + +CREATE TRIGGER trigger_ordre_drafts_updated_at +BEFORE UPDATE ON ordre_drafts +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/134_subscription_billing_dates.sql b/migrations/134_subscription_billing_dates.sql new file mode 100644 index 0000000..f514e82 --- /dev/null +++ b/migrations/134_subscription_billing_dates.sql @@ -0,0 +1,22 @@ +-- Migration 134: Add billing period tracking to subscriptions +-- Adds next_invoice_date and period_start fields for tracking billing cycles + +ALTER TABLE sag_subscriptions +ADD COLUMN IF NOT EXISTS next_invoice_date DATE, +ADD COLUMN IF NOT EXISTS period_start DATE; + +-- Set default values for existing subscriptions +UPDATE sag_subscriptions +SET + next_invoice_date = start_date + INTERVAL '1 month', + period_start = start_date +WHERE next_invoice_date IS NULL AND status = 'active'; + +-- Create index for efficient querying of subscriptions due for invoicing +CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_next_invoice + ON sag_subscriptions(next_invoice_date) + WHERE status = 'active'; + +COMMENT ON COLUMN sag_subscriptions.next_invoice_date IS 'Next date when an invoice should be generated for this subscription'; +COMMENT ON COLUMN sag_subscriptions.period_start IS 'Start date of the current billing period - shifts to next period when invoice is generated'; + diff --git a/migrations/135_subscription_extended_intervals.sql b/migrations/135_subscription_extended_intervals.sql new file mode 100644 index 0000000..cb855a4 --- /dev/null +++ b/migrations/135_subscription_extended_intervals.sql @@ -0,0 +1,17 @@ +-- Migration 135: Add daily and biweekly billing intervals +-- Extends subscription billing intervals to support more frequent billing + +-- Drop the old constraint +ALTER TABLE sag_subscriptions +DROP CONSTRAINT IF EXISTS sag_subscriptions_billing_interval_check; + +-- Add new constraint with extended options +ALTER TABLE sag_subscriptions +ADD CONSTRAINT sag_subscriptions_billing_interval_check +CHECK (billing_interval IN ('daily', 'biweekly', 'monthly', 'quarterly', 'yearly')); + +-- Update default if needed (keep monthly as default) +ALTER TABLE sag_subscriptions +ALTER COLUMN billing_interval SET DEFAULT 'monthly'; + +COMMENT ON COLUMN sag_subscriptions.billing_interval IS 'Billing frequency: daily, biweekly (every 14 days), monthly, quarterly, yearly'; diff --git a/migrations/136_simply_subscription_staging.sql b/migrations/136_simply_subscription_staging.sql new file mode 100644 index 0000000..78a8de6 --- /dev/null +++ b/migrations/136_simply_subscription_staging.sql @@ -0,0 +1,56 @@ +-- Migration 136: Simply CRM subscription staging (parking area) +-- Import recurring SalesOrders into staging, then approve per customer. + +CREATE TABLE IF NOT EXISTS simply_subscription_staging ( + id SERIAL PRIMARY KEY, + source_system VARCHAR(50) NOT NULL DEFAULT 'simplycrm', + source_record_id VARCHAR(64) NOT NULL, + source_account_id VARCHAR(64), + source_customer_name VARCHAR(255), + source_customer_cvr VARCHAR(32), + source_salesorder_no VARCHAR(100), + source_subject TEXT, + source_status VARCHAR(50), + source_start_date DATE, + source_end_date DATE, + source_binding_end_date DATE, + source_billing_frequency VARCHAR(50), + source_total_amount NUMERIC(12,2) DEFAULT 0, + source_currency VARCHAR(10) DEFAULT 'DKK', + source_raw JSONB NOT NULL, + sync_hash VARCHAR(64), + + hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + hub_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL, + + approval_status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (approval_status IN ('pending', 'mapped', 'approved', 'error')), + approval_error TEXT, + approved_at TIMESTAMP, + approved_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + + import_batch_id UUID, + imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_simply_subscription_staging_source UNIQUE (source_system, source_record_id) +); + +CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_status + ON simply_subscription_staging(approval_status); + +CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_account + ON simply_subscription_staging(source_account_id); + +CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_hub_customer + ON simply_subscription_staging(hub_customer_id); + +CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_batch + ON simply_subscription_staging(import_batch_id); + +DROP TRIGGER IF EXISTS trigger_simply_subscription_staging_updated_at ON simply_subscription_staging; +CREATE TRIGGER trigger_simply_subscription_staging_updated_at +BEFORE UPDATE ON simply_subscription_staging +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); diff --git a/move_time_section.py b/move_time_section.py new file mode 100644 index 0000000..a7a89a9 --- /dev/null +++ b/move_time_section.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Move time tracking section from right column to left column with inline quick-add +""" + +# Read the file +with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f: + lines = f.readlines() + +# Find the insertion point in left column (before
that closes left column around line 2665) +# and the section to remove in right column (around lines 2813-2865) + +# First, find where to insert (before the that closes left column) +insert_index = None +for i, line in enumerate(lines): + if i >= 2660 and i <= 2670: + if '' in line and 'col-lg-4' in lines[i+1]: + insert_index = i + break + +print(f"Found insert point at line {insert_index + 1}") + +# Find where to remove (the time card in right column) +remove_start = None +remove_end = None +for i, line in enumerate(lines): + if i >= 2810 and i <= 2820: + if 'data-module="time"' in line: + remove_start = i - 1 # Start from blank line before + break + +if remove_start: + # Find the end of this card + for i in range(remove_start, min(remove_start + 100, len(lines))): + if '' in lines[i] and i > remove_start + 50: # Make sure we've gone past the card content + remove_end = i + 1 # Include the closing div + break + +print(f"Found remove section from line {remove_start + 1} to {remove_end + 1}") + +# Create the new time tracking section with inline quick-add +new_time_section = ''' + +
+
+
Tid & Fakturering
+ +
+
+ +
+
+
+ + +
+
+ +
+ + : + +
+
+
+ + +
+
+ +
+
+
+ + +
+ + + + + + + + + + + {% for entry in time_entries %} + + + + + + + {% else %} + + + + {% endfor %} + +
DatoBeskrivelseBrugerTimer
{{ entry.worked_date }}{{ entry.description or '-' }}{{ entry.user_name }}{{ entry.original_hours }}
Ingen tid registreret
+
+ + + {% if prepaid_cards %} +
+
Klippekort
+
+ {% for card in prepaid_cards %} +
+
+ #{{ card.card_number or card.id }} + {{ '%.2f' % card.remaining_hours }}t tilbage +
+
+ {% endfor %} +
+
+ {% endif%} +
+
+ + + +''' + +# Build the new file +new_lines = [] + +# Copy lines up to insert point +new_lines.extend(lines[:insert_index]) + +# Insert new time section +new_lines.append(new_time_section) + +# Copy lines from insert point to remove start +new_lines.extend(lines[insert_index:remove_start]) + +# Skip the remove section, copy from remove_end onwards +new_lines.extend(lines[remove_end:]) + +# Write the new file +with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f: + f.writelines(new_lines) + +print(f"✅ File updated successfully!") +print(f" - Inserted new time section at line {insert_index + 1}") +print(f" - Removed old time section (lines {remove_start + 1} to {remove_end + 1})") +print(f" - New file has {len(new_lines)} lines (was {len(lines)} lines)") diff --git a/static/js/telefoni.js b/static/js/telefoni.js index 8b754fc..18038cc 100644 --- a/static/js/telefoni.js +++ b/static/js/telefoni.js @@ -65,6 +65,8 @@ const number = data.number || ''; const title = contact?.name ? contact.name : 'Ukendt nummer'; const company = contact?.company ? contact.company : ''; + const recentCases = data.recent_cases || []; + const lastCall = data.last_call; const callId = data.call_id; @@ -78,6 +80,54 @@ ? `` : ''; + // Build recent cases HTML + let casesHtml = ''; + if (recentCases.length > 0) { + casesHtml = '
Åbne sager:'; + recentCases.forEach(c => { + casesHtml += ``; + }); + casesHtml += '
'; + } + + // Build last call HTML + let lastCallHtml = ''; + if (lastCall) { + // lastCall can be either a date string (legacy) or an object with started_at and bruger_navn + const callDate = lastCall.started_at ? lastCall.started_at : lastCall; + const lastCallDate = new Date(callDate); + const now = new Date(); + const diffMs = now - lastCallDate; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + let timeAgo = ''; + if (diffDays === 0) { + timeAgo = 'I dag'; + } else if (diffDays === 1) { + timeAgo = 'I går'; + } else if (diffDays < 7) { + timeAgo = `${diffDays} dage siden`; + } else { + timeAgo = lastCallDate.toLocaleDateString('da-DK'); + } + + const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : ''; + + // Format duration + let durationInfo = ''; + if (lastCall.duration_sec) { + const mins = Math.floor(lastCall.duration_sec / 60); + const secs = lastCall.duration_sec % 60; + if (mins > 0) { + durationInfo = ` - ${mins}m ${secs}s`; + } else { + durationInfo = ` - ${secs}s`; + } + } + + lastCallHtml = `
Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}
`; + } + toastEl.innerHTML = `
Opkald @@ -88,6 +138,8 @@
${escapeHtml(number)}
${escapeHtml(title)}
${company ? `
${escapeHtml(company)}
` : ''} + ${lastCallHtml} + ${casesHtml}
${openContactBtn} diff --git a/test_subscription_processing.py b/test_subscription_processing.py new file mode 100644 index 0000000..40c9df6 --- /dev/null +++ b/test_subscription_processing.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +""" +Test script for subscription invoice processing +Run this manually to test the subscription processing job +""" +import asyncio +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.core.database import init_db +from app.jobs.process_subscriptions import process_subscriptions + + +async def main(): + """Run subscription processing test""" + print("🧪 Testing subscription invoice processing...") + print("=" * 60) + + # Initialize database connection pool + init_db() + print("✅ Database initialized") + + await process_subscriptions() + + print("=" * 60) + print("✅ Test complete") + + +if __name__ == "__main__": + asyncio.run(main())