-
Kontakt historik
-
+
+
Kommunikationshistorik
+ Opkald og SMS’er med kunden
+
+
diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py
index 098ea3d..12e2332 100644
--- a/app/emails/backend/router.py
+++ b/app/emails/backend/router.py
@@ -15,6 +15,7 @@ from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService
+from app.services.email_service import EmailService
from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier
@@ -2262,6 +2263,37 @@ async def reprocess_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e))
+@router.post("/emails/{email_id}/recover-attachments")
+async def recover_email_attachments(email_id: int):
+ """Recover a missing Graph attachment, then run the normal email workflow.
+
+ Intended for the old metadata-only attachment imports. It is safe to retry:
+ the normal invoice checksum and workflow safeguards remain in force.
+ """
+ try:
+ recovery = await EmailService().recover_graph_attachments(email_id)
+ if not recovery.get("success"):
+ raise HTTPException(status_code=409, detail=recovery.get("reason", "Could not recover attachments"))
+
+ email_rows = execute_query(
+ "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,)
+ )
+ if not email_rows:
+ raise HTTPException(status_code=404, detail="Email not found after recovery")
+ processing = await EmailProcessorService().process_single_email(email_rows[0])
+ return {
+ "success": True,
+ **recovery,
+ "workflows_executed": processing.get("workflows_executed", 0),
+ "awaiting_user_action": processing.get("awaiting_user_action", False),
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.exception("❌ Error recovering email attachments for %s", email_id)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.post("/emails/process")
async def process_emails(
limit: Optional[int] = Query(default=None, ge=1, le=500),
@@ -2379,10 +2411,10 @@ async def upload_emails(files: List[UploadFile] = File(...)):
logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity
- activity_logger.log_fetched(
+ await activity_logger.log_fetched(
email_id=email_id,
source="manual_upload",
- metadata={"filename": file.filename}
+ message_id=email_data.get("message_id", file.filename)
)
# Auto-classify
diff --git a/app/jobs/check_reminders.py b/app/jobs/check_reminders.py
index 662eae2..ed65826 100644
--- a/app/jobs/check_reminders.py
+++ b/app/jobs/check_reminders.py
@@ -29,6 +29,8 @@ async def check_reminders():
try:
logger.info("🔔 Checking for pending reminders...")
+ from app.modules.sag.backend.reminders import process_task_list_rules
+ await process_task_list_rules()
# Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue()
diff --git a/app/jobs/process_subscriptions.py b/app/jobs/process_subscriptions.py
index 0440cd7..035b2db 100644
--- a/app/jobs/process_subscriptions.py
+++ b/app/jobs/process_subscriptions.py
@@ -8,6 +8,7 @@ Runs daily at 04:00
import logging
from datetime import datetime, date
import json
+from typing import Optional, Sequence
from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection
@@ -20,7 +21,7 @@ from app.services.subscription_billing_calendar import (
logger = logging.getLogger(__name__)
-async def process_subscriptions():
+async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None):
"""
Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today
@@ -108,10 +109,19 @@ async def process_subscriptions():
SELECT 1 FROM subscription_billing_runs br
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
)
+ """
+ params = []
+ if subscription_ids is not None:
+ selected_ids = sorted({int(item) for item in subscription_ids})
+ if not selected_ids:
+ return
+ query += " AND s.id = ANY(%s)"
+ params.append(selected_ids)
+ query += """
ORDER BY s.next_invoice_date, s.id
"""
-
- subscriptions = execute_query(query)
+
+ subscriptions = execute_query(query, tuple(params))
if not subscriptions:
logger.info("✅ No subscriptions due for invoicing")
diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py
index 19de73b..3907743 100644
--- a/app/modules/bottom_bar/backend/service.py
+++ b/app/modules/bottom_bar/backend/service.py
@@ -971,6 +971,10 @@ def build_bottom_bar_state(
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
+ procurement_attention = execute_query_single(
+ """SELECT COUNT(*)::int AS count FROM sag_salgsvarer
+ WHERE type = 'purchase' AND status = 'draft'"""
+ ) or {"count": 0}
urgent_cases = execute_query(
"""
@@ -1155,6 +1159,10 @@ def build_bottom_bar_state(
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
},
+ "procurement": {
+ "to_order": int(procurement_attention.get("count") or 0),
+ "route": "/procurement",
+ },
"timer": {
"active_count": 1 if timer.get("active") else 0,
"list": timer_list,
diff --git a/app/modules/hardware/backend/router.py b/app/modules/hardware/backend/router.py
index 7659762..7962a92 100644
--- a/app/modules/hardware/backend/router.py
+++ b/app/modules/hardware/backend/router.py
@@ -8,11 +8,164 @@ from psycopg2.extras import Json
from datetime import datetime, date
import os
import uuid
+import secrets
+from fastapi import Header, status
+from pydantic import BaseModel, Field
+from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
+class MobileRecorderProvisionRequest(BaseModel):
+ """Payload posted by the Apple Configurator cfgutil provisioning script."""
+
+ name: str = Field(min_length=1, max_length=120)
+ asset_type: str = "mobile_recorder"
+ manufacturer: str = "Apple"
+ recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
+ model: Optional[str] = Field(default=None, max_length=100)
+ device_type: Optional[str] = Field(default=None, max_length=100)
+ serial_number: str = Field(min_length=1, max_length=100)
+ udid: Optional[str] = Field(default=None, max_length=160)
+ ecid: Optional[str] = Field(default=None, max_length=160)
+ imei: Optional[str] = Field(default=None, max_length=40)
+ wifi_mac: Optional[str] = Field(default=None, max_length=40)
+ os: str = "iOS"
+ os_version: Optional[str] = Field(default=None, max_length=40)
+ supervised: bool = False
+ status: str = "ready"
+
+
+def _provisioning_token_or_401(
+ authorization: Optional[str], x_provisioning_token: Optional[str],
+) -> None:
+ """Authenticate a headless provisioning client without accepting user JWTs."""
+ expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
+ if not expected:
+ logger.error("Mobile Recorder provisioning was called but no service token is configured")
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Provisioning endpoint is disabled: service token is not configured",
+ )
+ bearer = (authorization or "").strip()
+ supplied = (x_provisioning_token or "").strip()
+ if not supplied and bearer.lower().startswith("bearer "):
+ supplied = bearer[7:].strip()
+ if not supplied or not secrets.compare_digest(supplied, expected):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid provisioning token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+
+def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
+ return str(value).strip() if value is not None and str(value).strip() else None
+
+
+@router.post("/assets/provision", status_code=status.HTTP_200_OK)
+async def provision_mobile_recorder(
+ payload: MobileRecorderProvisionRequest,
+ authorization: Optional[str] = Header(default=None),
+ x_provisioning_token: Optional[str] = Header(default=None),
+):
+ """Create or update an Apple BMC Mobile Recorder by its physical serial number."""
+ _provisioning_token_or_401(authorization, x_provisioning_token)
+
+ if payload.asset_type != "mobile_recorder":
+ raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
+ if payload.status != "ready":
+ raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
+
+ serial_number = _clean_provisioning_value(payload.serial_number)
+ if not serial_number:
+ raise HTTPException(status_code=422, detail="serial_number is required")
+ manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
+ model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
+ recorder_name = _clean_provisioning_value(payload.name)
+
+ mobile_specs = {
+ "recorder_number": payload.recorder_number,
+ "name": recorder_name,
+ "udid": _clean_provisioning_value(payload.udid),
+ "ecid": _clean_provisioning_value(payload.ecid),
+ "imei": _clean_provisioning_value(payload.imei),
+ "wifi_mac": _clean_provisioning_value(payload.wifi_mac),
+ "os": _clean_provisioning_value(payload.os) or "iOS",
+ "os_version": _clean_provisioning_value(payload.os_version),
+ "supervised": bool(payload.supervised),
+ "provisioning_status": "ready",
+ "source": "apple_configurator",
+ }
+
+ existing = execute_query(
+ """SELECT id, hardware_specs FROM hardware_assets
+ WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
+ ORDER BY id LIMIT 2""",
+ (serial_number,),
+ ) or []
+ if len(existing) > 1:
+ raise HTTPException(
+ status_code=409,
+ detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
+ )
+
+ prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
+ if isinstance(prior_specs, str):
+ try:
+ prior_specs = json.loads(prior_specs)
+ except (TypeError, ValueError):
+ prior_specs = {}
+ if not isinstance(prior_specs, dict):
+ prior_specs = {}
+ prior_specs["mobile_recorder"] = mobile_specs
+
+ if existing:
+ asset_id = int(existing[0]["id"])
+ rows = execute_query(
+ """UPDATE hardware_assets
+ SET asset_type = 'mobile_recorder', brand = %s,
+ model = COALESCE(%s, model), internal_asset_id = %s,
+ recorder_number = %s, status = 'ready', hardware_specs = %s,
+ last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND deleted_at IS NULL
+ RETURNING id""",
+ (manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
+ )
+ action = "updated"
+ else:
+ rows = execute_query(
+ """INSERT INTO hardware_assets
+ (asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
+ current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
+ VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
+ CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ RETURNING id""",
+ (manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
+ )
+ action = "created"
+ asset_id = int(rows[0]["id"])
+ execute_query(
+ """INSERT INTO hardware_ownership_history
+ (hardware_id, owner_type, start_date, notes)
+ VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
+ (asset_id,),
+ fetch=False,
+ )
+
+ if not rows:
+ raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
+ execute_query(
+ """INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
+ VALUES (%s, %s, %s)""",
+ (asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
+ fetch=False,
+ )
+ logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
+ return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
+
+
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None:
return None
diff --git a/app/modules/internet_connections/backend/change_case_service.py b/app/modules/internet_connections/backend/change_case_service.py
index 9cc691d..7ae10ce 100644
--- a/app/modules/internet_connections/backend/change_case_service.py
+++ b/app/modules/internet_connections/backend/change_case_service.py
@@ -125,7 +125,7 @@ def ensure_external_change_case(
else:
row = execute_query_single(
"""INSERT INTO sag_sager
- (titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id)
+ (titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id)
VALUES (%s,%s,'indkøb','åben',%s,%s,1) RETURNING id""",
(title, description, case_customer_id, _economy_group_id()),
)
@@ -166,3 +166,38 @@ def ensure_external_change_case(
except Exception:
logger.exception("Could not persist failed internet change-case audit")
return {"case_id": None, "created": False, "changes": relevant, "error": str(exc)}
+
+
+def retry_failed_change_cases(source_type: str, source_key: str) -> dict[str, int]:
+ """Retry audit rows after a transient case-creation failure.
+
+ Imports are intentionally idempotent, so a repeated invoice normally does
+ not apply connection data again. Failed audit rows must nevertheless be
+ repairable once the underlying case service has been fixed.
+ """
+ rows = execute_query(
+ """SELECT audit.connection_id, audit.source_label, audit.source_url, audit.changes,
+ ic.name, ic.circuit_number, ic.provider, ic.customer_id
+ FROM internet_connection_change_cases audit
+ JOIN internet_connections_connections ic ON ic.id = audit.connection_id
+ WHERE audit.source_type=%s AND audit.source_key=%s
+ AND audit.sag_id IS NULL AND audit.last_error IS NOT NULL
+ AND ic.deleted_at IS NULL""",
+ (source_type, source_key),
+ ) or []
+ repaired = 0
+ failed = 0
+ for row in rows:
+ result = ensure_external_change_case(
+ connection_id=int(row["connection_id"]), source_type=source_type, source_key=source_key,
+ source_label=str(row.get("source_label") or source_key), changes=row.get("changes") or {},
+ connection_name=str(row.get("name") or "Internetforbindelse"),
+ reference=str(row.get("circuit_number") or ""),
+ provider=str(row.get("provider") or ""), owner_customer_id=row.get("customer_id"),
+ source_url=row.get("source_url"),
+ )
+ if result.get("case_id"):
+ repaired += 1
+ else:
+ failed += 1
+ return {"repaired": repaired, "failed": failed}
diff --git a/app/modules/internet_connections/backend/router.py b/app/modules/internet_connections/backend/router.py
index a453fb3..32e7960 100644
--- a/app/modules/internet_connections/backend/router.py
+++ b/app/modules/internet_connections/backend/router.py
@@ -1248,7 +1248,18 @@ class QuickBmcnetCreatePayload(BaseModel):
address: Optional[str] = None
billing_interval: str = "monthly"
billing_day: int = 1
+ billing_schedule_type: str = "fixed_day"
+ billing_direction: str = "forward"
+ advance_months: int = 1
+ billing_lead_months: int = 0
+ first_invoice_policy: str = "start_date"
start_date: date
+ period_start: Optional[date] = None
+ first_full_period_start: Optional[date] = None
+ end_date: Optional[date] = None
+ notice_period_days: int = 30
+ binding_months: int = 0
+ binding_start_date: Optional[date] = None
internet_product_id: int
internet_unit_price: Optional[float] = None
ip_product_id: Optional[int] = None
@@ -1259,6 +1270,12 @@ class QuickBmcnetCreatePayload(BaseModel):
mark_gateway: bool = False
+class DelefiberProductPricePayload(BaseModel):
+ product_id: int
+ monthly_price: float
+ notes: Optional[str] = None
+
+
def _load_subscription(subscription_id: int) -> Dict[str, Any]:
subscription = execute_query_single(
"""
@@ -3023,6 +3040,64 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
release_db_connection(conn)
+@router.get("/internet-connections/{connection_id}/product-prices", response_model=List[dict])
+async def list_delefiber_product_prices(connection_id: int):
+ head = execute_query_single(
+ "SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL",
+ (connection_id,),
+ )
+ if not head:
+ raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet")
+ return execute_query(
+ """SELECT price.id, price.product_id, price.monthly_price, price.notes, price.is_active,
+ price.updated_at, product.name AS product_name, product.sales_price AS product_sales_price
+ FROM internet_connections_delefiber_product_prices price
+ JOIN products product ON product.id=price.product_id AND product.deleted_at IS NULL
+ WHERE price.connection_id=%s AND price.is_active=true
+ ORDER BY product.name""",
+ (connection_id,),
+ ) or []
+
+
+@router.post("/internet-connections/{connection_id}/product-prices", response_model=dict)
+async def upsert_delefiber_product_price(connection_id: int, payload: DelefiberProductPricePayload):
+ head = execute_query_single(
+ "SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL",
+ (connection_id,),
+ )
+ if not head:
+ raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet")
+ product = execute_query_single("SELECT id FROM products WHERE id=%s AND deleted_at IS NULL", (payload.product_id,))
+ if not product:
+ raise HTTPException(status_code=404, detail="Produktet blev ikke fundet")
+ if payload.monthly_price < 0:
+ raise HTTPException(status_code=400, detail="Prisen må ikke være negativ")
+ row = execute_query_single(
+ """INSERT INTO internet_connections_delefiber_product_prices
+ (connection_id,product_id,monthly_price,notes,is_active)
+ VALUES (%s,%s,%s,%s,true)
+ ON CONFLICT (connection_id,product_id) DO UPDATE
+ SET monthly_price=EXCLUDED.monthly_price, notes=EXCLUDED.notes,
+ is_active=true, updated_at=NOW()
+ RETURNING id,connection_id,product_id,monthly_price,notes,is_active,updated_at""",
+ (connection_id, payload.product_id, payload.monthly_price, (payload.notes or '').strip() or None),
+ )
+ return dict(row)
+
+
+@router.delete("/internet-connections/{connection_id}/product-prices/{product_id}", response_model=dict)
+async def remove_delefiber_product_price(connection_id: int, product_id: int):
+ updated = execute_query(
+ """UPDATE internet_connections_delefiber_product_prices
+ SET is_active=false, updated_at=NOW()
+ WHERE connection_id=%s AND product_id=%s AND is_active=true""",
+ (connection_id, product_id), fetch=False,
+ )
+ if not updated:
+ raise HTTPException(status_code=404, detail="Prislinjen blev ikke fundet")
+ return {"deleted": True}
+
+
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
head_row = execute_query_single(
@@ -3074,9 +3149,25 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
if internet_profile.get("kind") != "internet_access":
raise HTTPException(status_code=400, detail="Det valgte internetprodukt er ikke et netværksprodukt")
+ # A local delefiber price is the default offer at this address. An explicit
+ # value in the wizard remains a negotiated customer-specific override.
+ local_price_row = execute_query_single(
+ """SELECT monthly_price FROM internet_connections_delefiber_product_prices
+ WHERE connection_id=%s AND product_id=%s AND is_active=true""",
+ (connection_id, int(payload.internet_product_id)),
+ )
+ resolved_internet_unit_price = (
+ float(payload.internet_unit_price)
+ if payload.internet_unit_price is not None
+ else float(local_price_row["monthly_price"])
+ if local_price_row and local_price_row.get("monthly_price") is not None
+ else float(internet_product.get("sales_price") or 0)
+ )
+
ip_product = None
ip_profile = {}
selected_ip_address = None
+ resolved_ip_unit_price = None
if payload.ip_product_id:
ip_product = product_map.get(int(payload.ip_product_id))
if not ip_product:
@@ -3131,6 +3222,20 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
elif payload.range_id or payload.ip_address_id:
raise HTTPException(status_code=400, detail="Der er valgt IP-allokering uden et IP-produkt")
+ if ip_product:
+ local_ip_price = execute_query_single(
+ """SELECT monthly_price FROM internet_connections_delefiber_product_prices
+ WHERE connection_id=%s AND product_id=%s AND is_active=true""",
+ (connection_id, int(ip_product["id"])),
+ )
+ resolved_ip_unit_price = (
+ float(payload.ip_unit_price)
+ if payload.ip_unit_price is not None
+ else float(local_ip_price["monthly_price"])
+ if local_ip_price and local_ip_price.get("monthly_price") is not None
+ else float(ip_product.get("sales_price") or 0)
+ )
+
case_title = str(payload.case_title or "").strip() or f"BMCnet - {customer_name}"
description_lines = [
f"Hovedforbindelse: {head.get('name') or connection_id}",
@@ -3170,14 +3275,14 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
"product_id": int(internet_product["id"]),
"description": str(internet_product.get("short_description") or internet_product.get("name") or "").strip(),
"quantity": 1,
- "unit_price": float(payload.internet_unit_price if payload.internet_unit_price is not None else (internet_product.get("sales_price") or 0)),
+ "unit_price": resolved_internet_unit_price,
}]
if ip_product:
line_items.append({
"product_id": int(ip_product["id"]),
"description": str(ip_product.get("short_description") or ip_product.get("name") or "").strip(),
"quantity": 1,
- "unit_price": float(payload.ip_unit_price if payload.ip_unit_price is not None else (ip_product.get("sales_price") or 0)),
+ "unit_price": resolved_ip_unit_price,
})
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
@@ -3186,7 +3291,18 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
"sag_id": int(created_case["id"]),
"billing_interval": payload.billing_interval,
"billing_day": int(payload.billing_day),
+ "billing_schedule_type": payload.billing_schedule_type,
+ "billing_direction": payload.billing_direction,
+ "advance_months": int(payload.advance_months),
+ "billing_lead_months": int(payload.billing_lead_months),
+ "first_invoice_policy": payload.first_invoice_policy,
"start_date": payload.start_date.isoformat(),
+ "period_start": (payload.period_start or payload.start_date).isoformat(),
+ "first_full_period_start": (payload.first_full_period_start.isoformat() if payload.first_full_period_start else None),
+ "end_date": (payload.end_date.isoformat() if payload.end_date else None),
+ "notice_period_days": int(payload.notice_period_days),
+ "binding_months": int(payload.binding_months),
+ "binding_start_date": (payload.binding_start_date or payload.period_start or payload.start_date).isoformat(),
"notes": wizard_notes,
"line_items": line_items,
})
diff --git a/app/modules/internet_connections/templates/detail.html b/app/modules/internet_connections/templates/detail.html
index 045315a..66d7a54 100644
--- a/app/modules/internet_connections/templates/detail.html
+++ b/app/modules/internet_connections/templates/detail.html
@@ -654,6 +654,12 @@
+
+
BMCnet standardpriser
Priser gælder kun for denne delefiber/adresse.
+
+
+
+
@@ -815,6 +821,7 @@
+
Periode og aftalevilkår — valgfrit
@@ -1174,6 +1181,44 @@
)).join('');
}
+ let delefiberProductPrices = [];
+
+ function isDelefiber(connection = currentConnection) {
+ return Boolean(connection && !connection.parent_id && (connection.is_shared_head || connection.allocation_model === 'shared'));
+ }
+
+ async function loadDelefiberProductPrices() {
+ const panel = document.getElementById('delefiberProductPricesPanel');
+ if (!panel || !isDelefiber()) { panel?.classList.add('d-none'); return; }
+ panel.classList.remove('d-none');
+ const select = document.getElementById('delefiberPriceProduct');
+ const priceProducts = bmcnetWizardProducts.filter((product) => ['internet_access', 'ip_allocation'].includes(parseProductAttributes(product.attributes_json)?.network?.kind));
+ select.innerHTML = '
' + priceProducts.map((product) => `
`).join('');
+ try {
+ const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`);
+ if (!response.ok) throw new Error('Kunne ikke hente standardpriser');
+ delefiberProductPrices = await response.json();
+ const list = document.getElementById('delefiberProductPricesList');
+ list.innerHTML = delefiberProductPrices.length ? delefiberProductPrices.map((row) => `
${escapeHtml(row.product_name || '-')}${formatDKK(row.monthly_price || 0)}/md.
`).join('') : '
Ingen lokale standardpriser endnu.
';
+ } catch (error) { document.getElementById('delefiberProductPricesList').innerHTML = `
${escapeHtml(error.message)}
`; }
+ }
+
+ async function saveDelefiberProductPrice() {
+ const productId = Number(document.getElementById('delefiberPriceProduct').value || 0);
+ const amount = document.getElementById('delefiberPriceAmount').value;
+ if (!productId || amount === '') { document.getElementById('detailSaveFeedback').textContent = 'Vælg produkt og pris.'; return; }
+ const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId,monthly_price:Number(amount)})});
+ if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke gemme standardpris.'); return; }
+ document.getElementById('delefiberPriceAmount').value = '';
+ await loadDelefiberProductPrices();
+ }
+
+ async function deleteDelefiberProductPrice(productId) {
+ const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices/${productId}`, {method:'DELETE'});
+ if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke fjerne standardpris.'); return; }
+ await loadDelefiberProductPrices();
+ }
+
function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => !item?.parent_id);
}
@@ -1276,7 +1321,7 @@
: await loadRangesForSharedHead(headId);
const availableRanges = filterAvailableBmcnetRanges(ranges);
rangeSelect.innerHTML = `
${availableRanges.map((range) => `
-
+
`).join('')}`;
hint.textContent = availableRanges.length
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
@@ -1289,6 +1334,7 @@
const canCreateBmcnet = Boolean(connection && !connection.parent_id);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return;
+ const today = new Date().toISOString().slice(0, 10);
document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = '';
@@ -1297,7 +1343,15 @@
document.getElementById('bmcnetWizardAddress').value = connection.address || '';
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
document.getElementById('bmcnetWizardBillingDay').value = '1';
- document.getElementById('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10);
+ document.getElementById('bmcnetWizardPeriodStart').value = today;
+ document.getElementById('bmcnetWizardEndDate').value = '';
+ document.getElementById('bmcnetWizardNoticeDays').value = '30';
+ document.getElementById('bmcnetWizardBillingDirection').value = 'forward';
+ document.getElementById('bmcnetWizardAdvanceMonths').value = '1';
+ document.getElementById('bmcnetWizardLeadMonths').value = '0';
+ document.getElementById('bmcnetWizardFirstInvoicePolicy').value = 'start_date';
+ document.getElementById('bmcnetWizardBindingMonths').value = '0';
+ document.getElementById('bmcnetWizardStartDate').value = today;
document.getElementById('bmcnetWizardInternetProduct').value = '';
document.getElementById('bmcnetWizardIpProduct').value = '';
document.getElementById('bmcnetWizardInternetPrice').value = '';
@@ -1332,7 +1386,21 @@
input.value = '';
return;
}
- input.value = option.dataset.price || '';
+ const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value));
+ input.value = local ? Number(local.monthly_price || 0) : (option.dataset.price || '');
+ }
+
+ function selectIpProductForPrefix(prefixLength) {
+ const select = document.getElementById('bmcnetWizardIpProduct');
+ if (!select || !prefixLength) return;
+ const product = bmcnetWizardProducts.find((item) => {
+ const network = parseProductAttributes(item.attributes_json)?.network || {};
+ return network.kind === 'ip_allocation' && Number(network.ip_prefix_length) === Number(prefixLength);
+ });
+ if (!product) return;
+ select.value = String(product.id);
+ syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
+ refreshBmcnetWizardAllocationMode();
}
async function createBmcnetConnection() {
@@ -1357,7 +1425,15 @@
address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1),
+ billing_direction: document.getElementById('bmcnetWizardBillingDirection').value || 'forward',
+ advance_months: Number(document.getElementById('bmcnetWizardAdvanceMonths').value || 1),
+ billing_lead_months: Number(document.getElementById('bmcnetWizardLeadMonths').value || 0),
+ first_invoice_policy: document.getElementById('bmcnetWizardFirstInvoicePolicy').value || 'start_date',
start_date: document.getElementById('bmcnetWizardStartDate').value,
+ period_start: document.getElementById('bmcnetWizardPeriodStart').value || null,
+ end_date: document.getElementById('bmcnetWizardEndDate').value || null,
+ notice_period_days: Number(document.getElementById('bmcnetWizardNoticeDays').value || 0),
+ binding_months: Number(document.getElementById('bmcnetWizardBindingMonths').value || 0),
internet_product_id: internetProductId,
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
? Number(document.getElementById('bmcnetWizardInternetPrice').value)
@@ -1604,6 +1680,7 @@
renderConnectionCases(cases);
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
+ await loadDelefiberProductPrices();
renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts);
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
@@ -2498,6 +2575,14 @@
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
await refreshBmcnetWizardAllocationMode();
});
+ document.getElementById('bmcnetWizardRangeSelect').addEventListener('change', (event) => {
+ const option = event.target.options[event.target.selectedIndex];
+ selectIpProductForPrefix(option?.dataset?.prefix);
+ });
+ document.getElementById('bmcnetWizardIpSelect').addEventListener('change', () => {
+ // A single selected public address is sold as a Static WAN IP.
+ if (document.getElementById('bmcnetWizardIpSelect').value) selectIpProductForPrefix(32);
+ });
document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
await refreshBmcnetWizardAllocationMode();
});
diff --git a/app/modules/sag/backend/reminders.py b/app/modules/sag/backend/reminders.py
index 16add6d..63ea324 100644
--- a/app/modules/sag/backend/reminders.py
+++ b/app/modules/sag/backend/reminders.py
@@ -5,11 +5,13 @@ CRUD operations, user preferences, snooze/dismiss functionality
import logging
from typing import List, Optional
+import json
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
+from app.core.config import settings
from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service
@@ -19,6 +21,94 @@ router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
+def _ensure_task_list_rules():
+ execute_query("""CREATE TABLE IF NOT EXISTS reminder_task_list_rules (
+ id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL,
+ times_json JSONB NOT NULL DEFAULT '[]'::jsonb, include_groups BOOLEAN NOT NULL DEFAULT true,
+ notify_mattermost BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, last_sent_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)""", fetch=False)
+ execute_query("ALTER TABLE reminder_task_list_rules ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP", fetch=False)
+
+def _task_list(user_id: int, include_groups: bool):
+ group_filter = "OR s.assigned_group_id IN (SELECT group_id FROM user_groups WHERE user_id=%s)" if include_groups else ""
+ params = (user_id, user_id) if include_groups else (user_id,)
+ return execute_query(f"""SELECT s.id, s.titel, s.status, s.priority, s.deadline, c.name AS customer_name
+ FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
+ WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) NOT IN ('lukket','løst','closed','resolved','udsat','deferred')
+ AND (s.ansvarlig_bruger_id=%s {group_filter})
+ ORDER BY s.deadline NULLS LAST, s.updated_at DESC LIMIT 50""", params) or []
+
+async def _send_task_list(rule, user_id: int):
+ tasks = _task_list(user_id, bool(rule.get('include_groups')))
+ if not tasks: return {'sent': False, 'message': 'Ingen åbne sager at sende'}
+ base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").rstrip('/')
+
+ def cell(value, fallback='—'):
+ value = str(value or fallback).replace('|', '\\|').replace('\n', ' ')
+ return value[:120]
+
+ lines = [
+ '| Sag | Kunde | Status | Prioritet | Deadline |',
+ '| :-- | :-- | :-- | :-- | :-- |',
+ ]
+ for row in tasks:
+ title = cell(row.get('titel'), 'Uden titel')
+ link = f"[#{row['id']} · {title}]({base_url}/sag/{row['id']}/v3)"
+ deadline = row.get('deadline')
+ if hasattr(deadline, 'strftime'):
+ deadline = deadline.strftime('%d.%m.%Y')
+ lines.append(
+ f"| {link} | {cell(row.get('customer_name'))} | {cell(row.get('status'))} "
+ f"| {cell(row.get('priority'))} | {cell(deadline)} |"
+ )
+ result = await reminder_notification_service.send_reminder(
+ reminder_id=0, sag_id=int(tasks[0]['id']), case_title='Opgaveliste', customer_name=None,
+ reminder_title=rule['title'], reminder_message='\n'.join(lines), recipient_user_ids=[user_id],
+ recipient_emails=[], priority='normal', notify_mattermost=bool(rule.get('notify_mattermost')),
+ notify_email=False, notify_frontend=True, override_user_preferences=True)
+ return {'sent': bool(result.get('success')), 'count': len(tasks), 'result': result}
+
+async def process_task_list_rules():
+ _ensure_task_list_rules()
+ now = datetime.now()
+ window_start = now - timedelta(minutes=5)
+ for rule in execute_query("SELECT * FROM reminder_task_list_rules WHERE is_active=true") or []:
+ times = rule.get('times_json') or []
+ if isinstance(times, str): times = json.loads(times)
+ for scheduled_time in times:
+ try:
+ hour, minute = (int(value) for value in scheduled_time.split(':', 1))
+ scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
+ except (AttributeError, TypeError, ValueError):
+ logger.warning("Ignoring invalid task-list schedule %r for rule %s", scheduled_time, rule['id'])
+ continue
+ last_sent = rule.get('last_sent_at')
+ if window_start < scheduled_at <= now and (not last_sent or last_sent < scheduled_at):
+ await _send_task_list(rule, int(rule['user_id']))
+ execute_query('UPDATE reminder_task_list_rules SET last_sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s',(rule['id'],),fetch=False)
+ break
+
+@router.get('/api/v1/reminder-task-rules')
+async def list_task_rules(request: Request):
+ _ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
+ return execute_query('SELECT * FROM reminder_task_list_rules WHERE user_id=%s ORDER BY id DESC',(user_id,)) or []
+
+@router.post('/api/v1/reminder-task-rules')
+async def save_task_rule(request: Request, data: dict):
+ _ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
+ title=str(data.get('title') or 'Min opgaveliste').strip(); times=[t for t in (data.get('times') or []) if isinstance(t,str)]
+ if not times: raise HTTPException(status_code=400, detail='Vælg mindst ét tidspunkt')
+ rows=execute_query("""INSERT INTO reminder_task_list_rules(user_id,title,times_json,include_groups,notify_mattermost)
+ VALUES(%s,%s,%s::jsonb,%s,%s) RETURNING *""",(user_id,title,json.dumps(times),bool(data.get('include_groups',True)),bool(data.get('notify_mattermost',True))))
+ return rows[0]
+
+@router.post('/api/v1/reminder-task-rules/{rule_id}/send-now')
+async def send_task_rule_now(rule_id:int, request:Request):
+ _ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
+ rule=execute_query("SELECT * FROM reminder_task_list_rules WHERE id=%s AND user_id=%s",(rule_id,user_id))
+ if not rule: raise HTTPException(status_code=404, detail='Reglen findes ikke')
+ return await _send_task_list(rule[0],user_id)
+
# ============================================================================
# Helper Functions
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index c43b963..015bb79 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -89,6 +89,23 @@ def _get_user_id_from_request(request: Request) -> int:
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
+def _ensure_case_internet_connection_tag(sag_id: int) -> None:
+ """Mark a case as internet-related without creating duplicate tags."""
+ existing = execute_query_single(
+ """SELECT id FROM sag_tags
+ WHERE sag_id = %s AND deleted_at IS NULL
+ AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
+ LIMIT 1""",
+ (sag_id,),
+ )
+ if not existing:
+ execute_query(
+ "INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
+ (sag_id,),
+ fetch=False,
+ )
+
+
def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
@@ -1019,8 +1036,8 @@ async def create_sag(request: Request, data: dict):
_validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
- pipeline = data.get("pipeline") if case_type == "pipeline" else None
- order_items = data.get("order_items") if case_type == "ordre" else []
+ pipeline = data.get("pipeline")
+ order_items = data.get("order_items", [])
raw_contact_ids = data.get("contact_ids") or []
if not isinstance(raw_contact_ids, list):
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
@@ -1158,6 +1175,16 @@ async def create_sag(request: Request, data: dict):
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(result["id"], connection_id, current_user_id),
)
+ cursor.execute(
+ """INSERT INTO sag_tags (sag_id, tag_navn)
+ SELECT %s, 'internet forbindelse'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM sag_tags
+ WHERE sag_id = %s AND deleted_at IS NULL
+ AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
+ )""",
+ (result["id"], result["id"]),
+ )
if telefoni_opkald_id:
cursor.execute(
@@ -1187,7 +1214,11 @@ async def create_sag(request: Request, data: dict):
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
)
+ from app.modules.sag.backend.create_support import attach_create_relations
+ tag_actions = attach_create_relations(cursor, result["id"], data, current_user_id)
conn.commit()
+ result = dict(result)
+ result["tag_actions"] = tag_actions
logger.info("✅ Case created: %s", result["id"])
return dict(result)
except Exception:
@@ -1229,6 +1260,7 @@ async def link_sag_internet_connection(sag_id: int, request: Request, data: dict
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
)
+ _ensure_case_internet_connection_tag(sag_id)
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
@@ -2576,6 +2608,28 @@ async def list_case_contacts(sag_id: int):
logger.error("❌ Error listing case contacts: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case contacts")
+
+@router.get("/sag/{sag_id}/reply-recipients")
+async def list_case_reply_recipients(sag_id: int, q: str = ""):
+ """Email-capable case contacts first, followed by contacts from the case customer."""
+ rows = execute_query(
+ """
+ WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS (
+ SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked
+ FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
+ WHERE sk.sag_id=%s AND sk.deleted_at IS NULL
+ UNION
+ SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked
+ FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id
+ ) SELECT DISTINCT ON (id) * FROM candidates
+ WHERE NULLIF(TRIM(email),'') IS NOT NULL
+ AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%')
+ ORDER BY id, linked DESC
+ """,
+ (sag_id, sag_id, q.strip(), q.strip()),
+ ) or []
+ return rows
+
@router.post("/sag/{sag_id}/contacts")
async def add_case_contact(sag_id: int, data: dict):
"""Add a contact to a case."""
@@ -3243,6 +3297,44 @@ async def list_sale_items(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list sale items")
+@router.get("/procurement/overview")
+async def procurement_overview():
+ """Operational queue for purchase lines awaiting order, receipt or delivery."""
+ rows = execute_query(
+ """
+ SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount,
+ p.status, p.line_date, p.external_ref, p.purchase_purpose,
+ p.supplier_invoice_id, p.supplier_invoice_line_id,
+ s.titel AS case_title, c.name AS customer_name,
+ EXISTS (
+ SELECT 1 FROM sag_salgsvarer sale
+ WHERE sale.sag_id = p.sag_id AND sale.type = 'sale'
+ AND sale.status <> 'cancelled'
+ ) AS has_sales_line,
+ CASE
+ WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received'
+ WHEN p.status = 'confirmed' THEN 'ordered'
+ ELSE 'to_order'
+ END AS fulfilment_state
+ FROM sag_salgsvarer p
+ JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE p.type = 'purchase' AND p.status <> 'cancelled'
+ ORDER BY CASE
+ WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2
+ WHEN p.status = 'confirmed' THEN 1 ELSE 0 END,
+ p.line_date NULLS LAST, p.id DESC
+ """
+ ) or []
+ counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0}
+ for row in rows:
+ state = row.get("fulfilment_state") or "to_order"
+ counts[state] = counts.get(state, 0) + 1
+ if not row.get("has_sales_line"):
+ counts["missing_sales_order"] += 1
+ return {"items": rows, "counts": counts}
+
+
@router.post("/sag/{sag_id}/sale-items")
async def create_sale_item(sag_id: int, data: dict):
"""Create a sale item for a case."""
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index 7ab36f4..a0e12e1 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -25,6 +25,15 @@ async def solutions_management(request: Request):
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
+@router.get("/procurement", response_class=HTMLResponse)
+async def procurement_overview_page(request: Request):
+ return templates.TemplateResponse("modules/sag/templates/procurement_overview.html", {"request": request})
+
+@router.get("/reminder-rules", response_class=HTMLResponse)
+async def reminder_rules_page(request: Request):
+ return templates.TemplateResponse("modules/sag/templates/reminder_rules.html", {"request": request})
+
+
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int):
article = execute_query(
diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html
index 076b75b..cc34d70 100644
--- a/app/modules/sag/templates/detail_v3.html
+++ b/app/modules/sag/templates/detail_v3.html
@@ -5119,6 +5119,15 @@
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }};
+ let caseHasInternetConnectionLink = false;
+
+ function caseHasInternetConnectionContext() {
+ if (caseHasInternetConnectionLink) return true;
+ return initialCaseTagsSnapshot.some((tag) => {
+ const value = String(tag?.tag_navn || tag?.name || '').trim().toLocaleLowerCase('da-DK');
+ return value === 'internet forbindelse';
+ });
+ }
async function markCaseAsRecentlyOpened() {
try {
@@ -5211,6 +5220,7 @@
'emails': 'E-mails',
'pipeline': 'Salgspipeline',
'hardware': 'Hardware',
+ 'internet-connections': 'Internetforbindelser',
'locations': 'Lokationer',
'contacts': 'Kontakter',
'customers': 'Kunder',
@@ -5274,6 +5284,7 @@
// Load Hardware & Locations
loadCaseHardware();
loadCaseLocations();
+ loadCaseInternetConnections();
loadCaseWiki();
loadTodoSteps();
loadCaseTagsModule();
@@ -5308,6 +5319,18 @@
});
}
+ const internetConnectionSearch = document.getElementById('case-internet-connection-search');
+ if (internetConnectionSearch) {
+ let timer;
+ internetConnectionSearch.addEventListener('input', () => {
+ clearTimeout(timer);
+ timer = setTimeout(searchCaseInternetConnections, 250);
+ });
+ internetConnectionSearch.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') toggleCaseInternetConnectionPicker(false);
+ });
+ }
+
['topbarStatusSelect', 'tabsAssignmentUserSelect', 'tabsAssignmentGroupSelect', 'topbarTypeSelect', 'topbarPrioritySelect', 'topbarStartDateInput', 'topbarDeferredInput', 'topbarDeadlineInput'].forEach((id) => {
const el = document.getElementById(id);
if (el) {
@@ -6200,6 +6223,110 @@
return div.innerHTML;
}
+ function toggleCaseInternetConnectionPicker(forceOpen) {
+ const picker = document.getElementById('case-internet-connection-picker');
+ const input = document.getElementById('case-internet-connection-search');
+ if (!picker) return;
+ const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : picker.classList.contains('d-none');
+ picker.classList.toggle('d-none', !shouldOpen);
+ if (shouldOpen) setTimeout(() => input?.focus(), 0);
+ }
+
+ async function loadCaseInternetConnections() {
+ const container = document.getElementById('case-internet-connections-list');
+ if (!container) return;
+ try {
+ const response = await fetch(`/api/v1/sag/${caseId}/internet-connections`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Kunne ikke hente internetforbindelser');
+ const items = await response.json();
+ if (items.length && !caseHasInternetConnectionLink) {
+ caseHasInternetConnectionLink = true;
+ applyViewLayout(currentCaseView);
+ }
+ const card = container.closest('.right-module-card');
+ if (card) card.dataset.hasContent = items.length ? 'true' : 'false';
+ if (!items.length) {
+ container.innerHTML = '
Ingen internetforbindelser tilknyttet
';
+ return;
+ }
+ container.innerHTML = items.map((item) => {
+ const name = item.name || item.circuit_number || item.provider_reference || `Forbindelse #${item.connection_id}`;
+ const meta = [item.circuit_number, item.provider_reference, item.address].filter(Boolean).map(escapeHtml).join(' · ');
+ return `
`;
+ }).join('');
+ } catch (error) {
+ console.error(error);
+ container.innerHTML = '
Kunne ikke hente forbindelser
';
+ }
+ }
+
+ async function searchCaseInternetConnections() {
+ const input = document.getElementById('case-internet-connection-search');
+ const results = document.getElementById('case-internet-connection-results');
+ if (!input || !results) return;
+ const query = input.value.trim();
+ if (query.length < 2) {
+ results.classList.add('d-none');
+ results.innerHTML = '';
+ return;
+ }
+ results.classList.remove('d-none');
+ results.innerHTML = '
Søger …
';
+ try {
+ const response = await fetch(`/api/v1/internet-connections?q=${encodeURIComponent(query)}`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Søgning fejlede');
+ const items = (await response.json()).slice(0, 8);
+ if (!items.length) {
+ results.innerHTML = '
Ingen forbindelser fundet
';
+ return;
+ }
+ results.innerHTML = items.map((item) => {
+ const name = item.name || item.circuit_number || item.provider_reference || `Forbindelse #${item.id}`;
+ const meta = [item.customer_name, item.circuit_number, item.address].filter(Boolean).map(escapeHtml).join(' · ');
+ return `
`;
+ }).join('');
+ } catch (error) {
+ results.innerHTML = '
Kunne ikke søge efter forbindelser
';
+ }
+ }
+
+ async function linkCaseInternetConnection(connectionId) {
+ try {
+ const response = await fetch(`/api/v1/sag/${caseId}/internet-connections`, {
+ method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({connection_id: connectionId})
+ });
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.detail || 'Kunne ikke tilknytte forbindelsen');
+ }
+ document.getElementById('case-internet-connection-search').value = '';
+ document.getElementById('case-internet-connection-results').classList.add('d-none');
+ toggleCaseInternetConnectionPicker(false);
+ caseHasInternetConnectionLink = true;
+ await loadCaseInternetConnections();
+ applyViewLayout(currentCaseView);
+ showCaseFeedback('Internetforbindelsen er tilknyttet sagen');
+ } catch (error) { showCaseFeedback(error.message || 'Kunne ikke tilknytte forbindelsen'); }
+ }
+
+ async function unlinkCaseInternetConnection(connectionId) {
+ try {
+ const response = await fetch(`/api/v1/sag/${caseId}/internet-connections/${connectionId}`, {method: 'DELETE', credentials: 'include'});
+ if (!response.ok) throw new Error('Kunne ikke fjerne forbindelsen');
+ await loadCaseInternetConnections();
+ } catch (error) { showCaseFeedback(error.message || 'Kunne ikke fjerne forbindelsen'); }
+ }
+
function sanitizeCaseEmailHtml(unsafeHtml) {
const input = String(unsafeHtml || '').trim();
if (!input) return '';
@@ -7671,6 +7798,25 @@