Implement tests for sag module, add knowledge base templates, and enhance internet connection migrations

- Added multiple test cases for the sag module to ensure proper functionality and data handling.
- Created new templates for knowledge detail and knowledge index pages to display articles and solutions.
- Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions.
- Added a script to reconcile known internet connections with verified data.
- Planned the implementation of a new website content administration module for managing customer references and operational status.
This commit is contained in:
Christian 2026-08-30 14:34:43 +02:00
parent ffdc9ac62c
commit adc4fb5876
37 changed files with 4147 additions and 747 deletions

View File

@ -705,6 +705,82 @@ def _normalize_provider_reference(value: Optional[str]) -> str:
return re.sub(r"[^A-Z0-9]", "", raw) return re.sub(r"[^A-Z0-9]", "", raw)
def _provider_reference_match_keys(value: Optional[str]) -> set[str]:
normalized = _normalize_provider_reference(value)
if not normalized:
return set()
keys = {normalized}
if normalized.startswith("DSLEB"):
keys.add(normalized[3:])
elif normalized.startswith("EB"):
keys.add(f"DSL{normalized}")
return keys
def _find_unique_globalconnect_connection_by_reference(reference: Optional[str]) -> Optional[int]:
target_keys = _provider_reference_match_keys(reference)
if not target_keys:
return None
rows = execute_query(
"""
SELECT id, circuit_number
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%'
AND NULLIF(BTRIM(circuit_number), '') IS NOT NULL
ORDER BY id
"""
) or []
matches = [row for row in rows if target_keys & _provider_reference_match_keys(row.get("circuit_number"))]
return int(matches[0]["id"]) if len(matches) == 1 else None
def _create_pending_connection_for_ip_reference(line: Dict, invoice_number: str) -> Optional[int]:
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
normalized_reference = _normalize_provider_reference(display_reference)
if not normalized_reference:
return None
existing_id = _find_unique_globalconnect_connection_by_reference(display_reference)
if existing_id:
return existing_id
service_address = _build_service_address(line)
connection_id = execute_insert(
"""
INSERT INTO internet_connections_connections (
name, provider, customer_id, address, status, monthly_cost, sales_price,
technology, connection_type, circuit_number, notes, allocation_model,
value_type, value_label
)
VALUES (%s, %s, NULL, %s, 'pending', 0, 0, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
f"Afventer mapping · {display_reference}",
"GlobalConnect A/S",
service_address,
"Internet",
"Internet",
display_reference,
f"Oprettet fra IP-range på faktura {invoice_number}. Kunde tildeles aldrig automatisk. Serviceadresse kræver manuel kontrol.",
"dedicated",
"other",
"Afventer manuel klassifikation",
),
)
return int(connection_id) if connection_id else None
def _canonical_ip_network(value: Optional[str]) -> str:
"""Canonicalize invoice IP/CIDR values before matching or persistence."""
raw = re.sub(r"\s+", "", str(value or "").strip())
if not raw:
return ""
try:
return str(ipaddress.ip_network(raw, strict=False))
except ValueError:
return ""
def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str: def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str:
parts = [f"Afventer mapping for {reference}."] parts = [f"Afventer mapping for {reference}."]
if end_customer_name: if end_customer_name:
@ -757,11 +833,9 @@ def _should_assign_internal_bmc_owner(
return False return False
if any(str(line.get("end_customer_name") or "").strip() for line in lines): if any(str(line.get("end_customer_name") or "").strip() for line in lines):
return False return False
if any(_looks_like_ip_range_line(line) for line in lines): description = " ".join(str(line.get("description") or "").lower() for line in lines)
return True explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit")
if len(lines) > 1: return any(marker in description for marker in explicit_shared_markers)
return True
return bool(service_address)
def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str: def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str:
@ -1068,17 +1142,21 @@ def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canoni
def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]: def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]:
matches = execute_query( canonical_cidr = _canonical_ip_network(cidr)
candidates = execute_query(
""" """
SELECT id SELECT id, cidr
FROM internet_connections_ip_ranges FROM internet_connections_ip_ranges
WHERE connection_id = %s WHERE connection_id = %s
AND cidr = %s
AND deleted_at IS NULL AND deleted_at IS NULL
ORDER BY id ORDER BY id
""", """,
(connection_id, cidr), (connection_id,),
) ) or []
matches = [
row for row in candidates
if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr
]
if not matches: if not matches:
return None return None
@ -1116,6 +1194,7 @@ def _normalize_service_address_for_match(value: Optional[str]) -> str:
def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]: def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
if not reference: if not reference:
return [] return []
match_keys = sorted(_provider_reference_match_keys(reference))
rows = execute_query( rows = execute_query(
""" """
SELECT id, customer_id, address, monthly_cost, technology, connection_type, SELECT id, customer_id, address, monthly_cost, technology, connection_type,
@ -1124,10 +1203,10 @@ def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
FROM internet_connections_connections FROM internet_connections_connections
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%' AND provider ILIKE 'GlobalConnect%%'
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = ANY(%s)
ORDER BY id ORDER BY id
""", """,
(reference,), (match_keys,),
) or [] ) or []
return [dict(row) for row in rows] return [dict(row) for row in rows]
@ -1216,44 +1295,30 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
if existing and merge_ids: if existing and merge_ids:
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"])) _merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers) # Supplier data may suggest a customer name, but customer ownership is
if not matched_customer and existing and existing.get("customer_id"): # always a manual CRM decision. Existing manually selected owners survive
# Preserve a previously reviewed owner when the new invoice has no # because update SQL uses COALESCE(NULL, customer_id); new records stay NULL.
# unambiguous customer name/address instead of replacing it with BMC. matched_customer = None
matched_customer = next(
(customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])),
None,
)
description = str(primary_line.get("description") or reference) description = str(primary_line.get("description") or reference)
end_customer_name = str(primary_line.get("end_customer_name") or "").strip() end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
# Internal BMC ownership is only a default for a newly discovered # Internal BMC ownership is only a default for a newly discovered
# connection. An existing connection with no customer may deliberately be # connection. An existing connection with no customer may deliberately be
# unassigned and must not gain an owner merely because a later invoice is # unassigned and must not gain an owner merely because a later invoice is
# ambiguous. # ambiguous.
internal_owner = ( is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address)
_resolve_internal_bmc_customer() internal_owner = None
if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address) owner_customer = None
else None is_confident = False
) connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}"
owner_customer = matched_customer or internal_owner
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
connection_name = (
end_customer_name or service_address or f"GlobalConnect {reference}"
if is_confident
else (f"{internal_owner['name']} · {display_reference}" if internal_owner else f"Afventer mapping · {display_reference}")
)
monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0")) monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0"))
note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description"))) note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description")))
base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}" base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference) mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference)
if internal_owner and not matched_customer: note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk."
note_text = f"{base_note} Ejer sat til intern BMC-kunde, da forbindelsen bruges som delt hovedforbindelse eller ikke kan bindes sikkert til én slutkunde."
else:
note_text = base_note if is_confident else f"{base_note} {mapping_note}"
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description) download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
target_status = "active" if (is_confident or internal_owner) else "pending" target_status = "pending"
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer) shared_value_type = "delefiber" if is_shared_candidate else "other"
payload = ( payload = (
connection_name, connection_name,
"GlobalConnect A/S", "GlobalConnect A/S",
@ -1267,14 +1332,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
upload_mbps, upload_mbps,
download_mbps, download_mbps,
note_text, note_text,
"shared" if internal_owner and not matched_customer else "dedicated", "shared" if is_shared_candidate else "dedicated",
shared_value_type, shared_value_type,
None, None,
) )
if existing: if existing:
updated_snapshot = { updated_snapshot = {
"customer_id": owner_customer["id"] if owner_customer else None, "customer_id": existing.get("customer_id"),
"address": service_address, "address": service_address,
"monthly_cost": monthly_cost, "monthly_cost": monthly_cost,
"technology": _infer_technology(description), "technology": _infer_technology(description),
@ -1284,7 +1349,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
"download_mbps": download_mbps, "download_mbps": download_mbps,
"upload_mbps": upload_mbps, "upload_mbps": upload_mbps,
"status": target_status, "status": target_status,
"allocation_model": "shared" if internal_owner and not matched_customer else "dedicated", "allocation_model": "shared" if is_shared_candidate else "dedicated",
"value_type": shared_value_type, "value_type": shared_value_type,
"value_label": None, "value_label": None,
} }
@ -1301,7 +1366,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
download_mbps, download_mbps,
upload_mbps, upload_mbps,
note_text, note_text,
"shared" if internal_owner and not matched_customer else "dedicated", "shared" if is_shared_candidate else "dedicated",
shared_value_type, shared_value_type,
None, None,
existing["id"], existing["id"],
@ -1333,7 +1398,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
update_payload[1], update_payload[1],
update_payload[2], update_payload[2],
update_payload[3], update_payload[3],
"active" if (is_confident or internal_owner) else "pending", target_status,
update_payload[4], update_payload[4],
update_payload[5], update_payload[5],
update_payload[6], update_payload[6],
@ -1398,7 +1463,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
payload[1], payload[1],
payload[2], payload[2],
payload[3], payload[3],
"active" if (is_confident or internal_owner) else "pending", target_status,
payload[4], payload[4],
payload[5], payload[5],
payload[6], payload[6],
@ -1429,13 +1494,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str): def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str):
cidr = str(line.get("ip_address") or "").strip() cidr = _canonical_ip_network(line.get("ip_address"))
if not connection_id or not cidr: if not connection_id or not cidr:
return None return None
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip() display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
service_address = _build_service_address(line) service_address = _build_service_address(line)
matched_customer = _match_customer_for_globalconnect_line(line, _load_active_customers_for_matching()) # Never infer range ownership from invoice text. A user must select it.
matched_customer = None
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr) canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr)
existing = execute_query_single( existing = execute_query_single(
""" """
@ -1593,25 +1659,26 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]: def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]:
"""Use an existing CIDR as the strongest key, but never cross service addresses.""" """Use an existing CIDR as the strongest key, but never cross service addresses."""
cidr = str(line.get("ip_address") or "").strip() cidr = _canonical_ip_network(line.get("ip_address"))
if not cidr: if not cidr:
return {"connection_id": None, "conflict_reason": None} return {"connection_id": None, "conflict_reason": None}
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line) service_address = _build_service_address(line)
rows = execute_query( rows = execute_query(
""" """
SELECT range.connection_id, range.service_address, range.provider_reference, SELECT range.connection_id, range.cidr, range.service_address, range.provider_reference,
connection.address AS connection_address, connection.address AS connection_address,
connection.circuit_number AS connection_reference connection.circuit_number AS connection_reference
FROM internet_connections_ip_ranges range FROM internet_connections_ip_ranges range
JOIN internet_connections_connections connection ON connection.id = range.connection_id JOIN internet_connections_connections connection ON connection.id = range.connection_id
WHERE range.cidr = %s WHERE range.deleted_at IS NULL
AND range.deleted_at IS NULL
AND connection.deleted_at IS NULL AND connection.deleted_at IS NULL
AND connection.provider ILIKE 'GlobalConnect%%'
ORDER BY range.id ORDER BY range.id
""", """,
(cidr,), (),
) or [] ) or []
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
if reference: if reference:
matching_reference = [ matching_reference = [
row for row in rows row for row in rows
@ -1829,24 +1896,51 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
for audit_index, line in ip_range_lines: for audit_index, line in ip_range_lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line) service_address = _build_service_address(line)
reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference)
existing_range_resolution = _resolve_existing_ip_range_connection(line) existing_range_resolution = _resolve_existing_ip_range_connection(line)
if existing_range_resolution.get("conflict_reason"): if existing_range_resolution.get("conflict_reason") and not reference_connection_id:
skipped_orphan_ip_ranges += 1 skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped" line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"] line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
continue continue
connection_id = existing_range_resolution.get("connection_id") or connection_map.get(reference) mapped_reference_connection_id = connection_map.get(reference)
# An existing CIDR on the same service address is stronger evidence than
# a supplier reference. OCR/extraction can accidentally carry a circuit
# number from a neighbouring invoice line.
address_range_connection_id = existing_range_resolution.get("connection_id")
connection_id = address_range_connection_id or reference_connection_id or mapped_reference_connection_id
resolved_from_existing = False resolved_from_existing = False
if existing_range_resolution.get("connection_id"): matched_by_reference = bool(
not address_range_connection_id
and (reference_connection_id or mapped_reference_connection_id)
)
if reference_connection_id or address_range_connection_id:
resolved_from_existing = True resolved_from_existing = True
corrected_service_address = None
if connection_id and not _connection_can_host_ip_range(connection_id, service_address): if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
if matched_by_reference:
authoritative_connection = execute_query_single(
"""
SELECT address
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(connection_id,),
) or {}
corrected_service_address = str(authoritative_connection.get("address") or "").strip() or None
if not corrected_service_address:
connection_id = None connection_id = None
if not connection_id and reference: else:
if not service_address: connection_id = None
line_audit[audit_index]["status"] = "skipped" if not connection_id:
line_audit[audit_index]["reason"] = "Mangler serviceadresse til IP-range" matched_by_reference = False
skipped_orphan_ip_ranges += 1 skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse"
continue continue
if not connection_id and reference:
connection_id, connection_conflict_reason = (None, None)
if service_address:
connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address) connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
if connection_id: if connection_id:
connection_map[reference] = connection_id connection_map[reference] = connection_id
@ -1856,8 +1950,29 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
line_audit[audit_index]["status"] = "skipped" line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = connection_conflict_reason line_audit[audit_index]["reason"] = connection_conflict_reason
continue continue
elif not simulate and not reference.startswith("EB"):
connection_id = _create_pending_connection_for_ip_reference(line, invoice_number)
if connection_id:
connection_map[reference] = connection_id
created_or_updated_connections += 1
created_connections += 1
line_audit[audit_index]["created_pending_connection"] = True
elif simulate and not reference.startswith("EB"):
connection_id = -(len(connection_map) + 1)
if matched_by_reference:
line_audit[audit_index]["matched_by"] = "unique_circuit_reference"
if service_address and not _connection_can_host_ip_range(connection_id, service_address):
line_audit[audit_index]["address_warning"] = "IP-linjens serviceadresse afviger fra forbindelsen; kredsløbsnummer blev brugt"
sync_line = dict(line) sync_line = dict(line)
if corrected_service_address:
sync_line["service_address"] = corrected_service_address
line_audit[audit_index]["address_warning"] = (
f"Fakturaadressen '{service_address}' blev erstattet med kredsløbets adresse "
f"'{corrected_service_address}'"
)
line_audit[audit_index]["service_address_corrected"] = True
if existing_range_resolution.get("canonical_reference"): if existing_range_resolution.get("canonical_reference"):
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"] sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
sync_line["circuit_id"] = existing_range_resolution["canonical_reference"] sync_line["circuit_id"] = existing_range_resolution["canonical_reference"]

View File

@ -89,6 +89,7 @@ class VendorBase(BaseModel):
priority: Optional[int] = 100 priority: Optional[int] = 100
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True is_active: bool = True
is_internet_provider: bool = False
class VendorCreate(VendorBase): class VendorCreate(VendorBase):
@ -103,10 +104,16 @@ class VendorUpdate(BaseModel):
domain: Optional[str] = None domain: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
economic_supplier_number: Optional[int] = None
contact_person: Optional[str] = None contact_person: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_internet_provider: Optional[bool] = None
class Vendor(VendorBase): class Vendor(VendorBase):
@ -159,6 +166,15 @@ class SolutionBase(BaseModel):
description: Optional[str] = None description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc. solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: str = "internal"
approval_status: str = "draft"
is_final: bool = True
tags: list[str] = Field(default_factory=list)
products: list[str] = Field(default_factory=list)
class SolutionCreate(SolutionBase): class SolutionCreate(SolutionBase):
"""Schema for creating a solution""" """Schema for creating a solution"""
@ -171,6 +187,16 @@ class SolutionUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
solution_type: Optional[str] = None solution_type: Optional[str] = None
result: Optional[str] = None result: Optional[str] = None
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: Optional[str] = None
approval_status: Optional[str] = None
is_final: Optional[bool] = None
tags: Optional[list[str]] = None
products: Optional[list[str]] = None
change_note: Optional[str] = None
class Solution(SolutionBase): class Solution(SolutionBase):
"""Full solution schema""" """Full solution schema"""
@ -179,6 +205,9 @@ class Solution(SolutionBase):
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
updated_by_user_id: Optional[int] = None
approved_by_user_id: Optional[int] = None
approved_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -4,6 +4,7 @@ import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary
@ -79,14 +80,14 @@ async def bottom_bar_ws(websocket: WebSocket):
initial_status = get_dashboard_status() initial_status = get_dashboard_status()
initial_notifications = get_notifications(user_id, limit=20) initial_notifications = get_notifications(user_id, limit=20)
initial_messages = get_user_messages_summary(user_id, limit=20) initial_messages = get_user_messages_summary(user_id, limit=20)
await websocket.send_json({"event": "status_delta", "data": initial_status}) await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": initial_status}))
await websocket.send_json({ await websocket.send_json(jsonable_encoder({
"event": "notification_delta", "event": "notification_delta",
"data": { "data": {
"notifications": initial_notifications, "notifications": initial_notifications,
"messages": initial_messages, "messages": initial_messages,
}, },
}) }))
last_status_json = json.dumps(initial_status, sort_keys=True, default=str) last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str) last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
@ -99,7 +100,7 @@ async def bottom_bar_ws(websocket: WebSocket):
timer = get_active_timer(user_id) timer = get_active_timer(user_id)
elapsed = int(timer.get("elapsed") or 0) elapsed = int(timer.get("elapsed") or 0)
if elapsed != last_timer_elapsed: if elapsed != last_timer_elapsed:
await websocket.send_json({"event": "timer_tick", "data": timer}) await websocket.send_json(jsonable_encoder({"event": "timer_tick", "data": timer}))
last_timer_elapsed = elapsed last_timer_elapsed = elapsed
status_tick += 1 status_tick += 1
@ -110,19 +111,19 @@ async def bottom_bar_ws(websocket: WebSocket):
status_json = json.dumps(status, sort_keys=True, default=str) status_json = json.dumps(status, sort_keys=True, default=str)
if status_json != last_status_json: if status_json != last_status_json:
await websocket.send_json({"event": "status_delta", "data": status}) await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": status}))
last_status_json = status_json last_status_json = status_json
notifications_json = json.dumps(notifications, sort_keys=True, default=str) notifications_json = json.dumps(notifications, sort_keys=True, default=str)
messages_json = json.dumps(messages, sort_keys=True, default=str) messages_json = json.dumps(messages, sort_keys=True, default=str)
if notifications_json != last_notifications_json or messages_json != last_messages_json: if notifications_json != last_notifications_json or messages_json != last_messages_json:
await websocket.send_json({ await websocket.send_json(jsonable_encoder({
"event": "notification_delta", "event": "notification_delta",
"data": { "data": {
"notifications": notifications, "notifications": notifications,
"messages": messages, "messages": messages,
}, },
}) }))
last_notifications_json = notifications_json last_notifications_json = notifications_json
last_messages_json = messages_json last_messages_json = messages_json

View File

@ -1,7 +1,11 @@
import ipaddress import ipaddress
import io
import logging import logging
import re import re
from datetime import date import zipfile
import xml.etree.ElementTree as ET
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -67,6 +71,88 @@ GENERIC_SEGMENT_TITLE_PATTERNS = (
"not in use", "not in use",
"untagged native management", "untagged native management",
) )
IP_NORDIC_IMPORT_HEADERS = {
"Company", "Name", "Startdate", "Salgspris", "Kostpris", "InstallationAddress"
}
def _excel_column_name(cell_reference: str) -> str:
match = re.match(r"[A-Z]+", str(cell_reference or "").upper())
return match.group(0) if match else ""
def _parse_ip_nordic_xlsx(content: bytes) -> List[Dict[str, Any]]:
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=413, detail="Excel-filen må højst fylde 10 MB")
namespace = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
shared_strings: List[str] = []
if "xl/sharedStrings.xml" in archive.namelist():
shared_root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
shared_strings = [
"".join(node.text or "" for node in item.iter(f"{namespace}t"))
for item in shared_root.findall(f"{namespace}si")
]
sheet_root = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
except (KeyError, zipfile.BadZipFile, ET.ParseError) as exc:
raise HTTPException(status_code=400, detail="Filen er ikke en gyldig IP Nordic Excel-fil") from exc
raw_rows: List[Dict[str, str]] = []
for row in sheet_root.findall(f".//{namespace}sheetData/{namespace}row"):
values: Dict[str, str] = {}
for cell in row.findall(f"{namespace}c"):
column = _excel_column_name(cell.attrib.get("r", ""))
value_node = cell.find(f"{namespace}v")
value = value_node.text if value_node is not None and value_node.text is not None else ""
if cell.attrib.get("t") == "s" and value:
value = shared_strings[int(value)]
elif cell.attrib.get("t") == "inlineStr":
value = "".join(node.text or "" for node in cell.iter(f"{namespace}t"))
values[column] = value
raw_rows.append(values)
if not raw_rows:
raise HTTPException(status_code=400, detail="Excel-filen er tom")
headers = {column: str(value).strip() for column, value in raw_rows[0].items()}
if not IP_NORDIC_IMPORT_HEADERS.issubset(set(headers.values())):
raise HTTPException(status_code=400, detail="Excel-filen mangler de forventede IP Nordic-kolonner")
columns = {header: column for column, header in headers.items()}
grouped: Dict[tuple[str, str], Dict[str, Any]] = {}
for row_number, raw in enumerate(raw_rows[1:], start=2):
address = re.sub(r"\s+", " ", str(raw.get(columns["InstallationAddress"], "")).strip())
company_number = str(raw.get(columns["Company"], "")).strip()
reported_company = str(raw.get(columns["Name"], "")).strip()
if not address:
continue
key = (company_number, _normalize_service_location(address))
item = grouped.setdefault(key, {
"company_number": company_number,
"reported_company": reported_company,
"address": address,
"start_date": None,
"sales_price": Decimal("0"),
"monthly_cost": Decimal("0"),
"line_count": 0,
})
item["line_count"] += 1
date_value = str(raw.get(columns["Startdate"], "")).strip()
if date_value:
try:
parsed_date = (datetime(1899, 12, 30) + timedelta(days=float(date_value))).date()
if item["start_date"] is None or parsed_date < item["start_date"]:
item["start_date"] = parsed_date
except ValueError:
raise HTTPException(status_code=400, detail=f"Ugyldig startdato på række {row_number}")
for header, target in (("Salgspris", "sales_price"), ("Kostpris", "monthly_cost")):
raw_amount = str(raw.get(columns[header], "")).strip()
if raw_amount and raw_amount.upper() != "NULL":
try:
item[target] += Decimal(raw_amount)
except InvalidOperation as exc:
raise HTTPException(status_code=400, detail=f"Ugyldigt beløb på række {row_number}") from exc
return list(grouped.values())
def _normalize_service_location(value: Optional[str]) -> str: def _normalize_service_location(value: Optional[str]) -> str:
@ -75,6 +161,22 @@ def _normalize_service_location(value: Optional[str]) -> str:
return re.sub(r"[^a-z0-9]+", "", normalized) return re.sub(r"[^a-z0-9]+", "", normalized)
def _address_match_components(value: Optional[str]) -> Dict[str, Any]:
text = str(value or "").strip().lower()
text = text.replace("boulevard", "blv").replace("allé", "alle")
postal_match = re.search(r"\b(\d{4})\b", text)
postal_code = postal_match.group(1) if postal_match else ""
street_part = text.split(postal_code, 1)[0] if postal_code else text
house_numbers = [int(number) for number in re.findall(r"\b(\d{1,4})\b", street_part)]
street_name = re.sub(r"\b\d{1,4}\b", " ", street_part)
street_name = re.sub(r"\b(st|sal|th|tv|mf)\b", " ", street_name)
return {
"postal_code": postal_code,
"street_name": _normalize_service_location(street_name),
"house_numbers": house_numbers,
}
class InvoiceSyncReviewRequest(BaseModel): class InvoiceSyncReviewRequest(BaseModel):
line_number: int line_number: int
action: str action: str
@ -209,14 +311,19 @@ def _extract_segment_entities(block: str) -> Dict[str, List[str]]:
references = [] references = []
socket_numbers = [] socket_numbers = []
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}/\d{1,2}\b", block): raw_cidr_values = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\s*/\s*\d{1,2}\b", block)
normalized = raw.strip() cidr_literal_ips = {re.sub(r"\s+", "", raw).split("/", 1)[0] for raw in raw_cidr_values}
for raw in raw_cidr_values:
try:
normalized = str(ipaddress.ip_network(re.sub(r"\s+", "", raw), strict=False))
except ValueError:
continue
if normalized not in cidr_blocks: if normalized not in cidr_blocks:
cidr_blocks.append(normalized) cidr_blocks.append(normalized)
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", block): for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", block):
normalized = raw.strip() normalized = raw.strip()
if any(normalized == cidr.split("/")[0] for cidr in cidr_blocks): if normalized in cidr_literal_ips:
continue continue
try: try:
normalized_ip = _normalize_ip_address(normalized) normalized_ip = _normalize_ip_address(normalized)
@ -652,6 +759,72 @@ def _create_history_entry(connection_id: int, event_type: str, summary: str, det
) )
def _sync_bmcnet_parent_classification(parent_connection_id: Optional[int]) -> bool:
"""Derive a head connection's classification from its non-deleted BMCnet children."""
if not parent_connection_id:
return False
parent = execute_query_single(
"""
SELECT id, allocation_model, value_type, value_label, is_manual_shared
FROM internet_connections_connections
WHERE id = %s AND parent_id IS NULL AND deleted_at IS NULL
LIMIT 1
""",
(parent_connection_id,),
)
if not parent:
return False
child_stats = execute_query_single(
"""
SELECT COUNT(*) AS child_count
FROM internet_connections_connections
WHERE parent_id = %s
AND deleted_at IS NULL
AND (
value_type = 'subscription'
OR LOWER(COALESCE(value_label, '')) IN ('bmcnet', 'bmc networks')
)
""",
(parent_connection_id,),
) or {}
child_count = int(child_stats.get("child_count") or 0)
should_be_shared = child_count > 0 or bool(parent.get("is_manual_shared"))
allocation_model = "shared" if should_be_shared else "dedicated"
value_type = "delefiber" if should_be_shared else "other"
value_label = None if should_be_shared else "Internetforbindelse"
if (
parent.get("allocation_model") == allocation_model
and parent.get("value_type") == value_type
and parent.get("value_label") == value_label
):
return False
execute_query(
"""
UPDATE internet_connections_connections
SET allocation_model = %s, value_type = %s, value_label = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND parent_id IS NULL AND deleted_at IS NULL
""",
(allocation_model, value_type, value_label, parent_connection_id),
)
_create_history_entry(
int(parent_connection_id),
"bmcnet_classification_changed",
"Hovedforbindelsen blev klassificeret som delefiber" if should_be_shared
else "Hovedforbindelsen blev klassificeret som dedikeret",
{
"bmcnet_child_count": child_count,
"allocation_model": allocation_model,
"value_type": value_type,
},
)
return True
def _create_ip_addresses_for_range(range_id: int, cidr: str): def _create_ip_addresses_for_range(range_id: int, cidr: str):
network = _validate_cidr(cidr) network = _validate_cidr(cidr)
addresses = [] addresses = []
@ -743,7 +916,7 @@ def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
if value_type == "other": if value_type == "other":
if not value_label: if not value_label:
value_label = "Mangler klassifikation" raise HTTPException(status_code=400, detail="value_label is required when value_type is other")
else: else:
value_label = None value_label = None
@ -752,6 +925,12 @@ def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
normalized["value_type"] = value_type normalized["value_type"] = value_type
normalized["value_label"] = value_label normalized["value_label"] = value_label
normalized["subscription_id"] = subscription_id normalized["subscription_id"] = subscription_id
normalized["is_manual_shared"] = bool(
normalized.get("is_manual_shared")
and not normalized.get("parent_id")
and allocation_model == "shared"
and value_type == "delefiber"
)
return normalized return normalized
@ -762,6 +941,8 @@ def _connection_select_sql(where_sql: str = "") -> str:
ic.parent_id, ic.parent_id,
ic.name, ic.name,
ic.provider, ic.provider,
ic.vendor_id,
vendor.name AS vendor_name,
ic.customer_id, ic.customer_id,
c.name AS customer_name, c.name AS customer_name,
parent.name AS parent_name, parent.name AS parent_name,
@ -784,10 +965,16 @@ def _connection_select_sql(where_sql: str = "") -> str:
ic.allocation_model, ic.allocation_model,
ic.value_type, ic.value_type,
ic.value_label, ic.value_label,
ic.is_manual_shared,
ic.subscription_id, ic.subscription_id,
ic.sla_subscription_id,
sub.subscription_number, sub.subscription_number,
sub.product_name AS subscription_product_name, sub.product_name AS subscription_product_name,
subc.name AS subscription_customer_name, subc.name AS subscription_customer_name,
sla.subscription_number AS sla_subscription_number,
sla.product_name AS sla_product_name,
sla.price AS sla_price,
sla.status AS sla_status,
COALESCE(ip_stats.range_count, 0) AS ip_range_count, COALESCE(ip_stats.range_count, 0) AS ip_range_count,
COALESCE(ip_stats.total_addresses, 0) AS total_ip_addresses, COALESCE(ip_stats.total_addresses, 0) AS total_ip_addresses,
COALESCE(ip_stats.in_use_addresses, 0) AS in_use_ip_addresses, COALESCE(ip_stats.in_use_addresses, 0) AS in_use_ip_addresses,
@ -801,9 +988,11 @@ def _connection_select_sql(where_sql: str = "") -> str:
COALESCE(child_stats.bmcnet_child_ip_count, 0) AS bmcnet_child_ip_count COALESCE(child_stats.bmcnet_child_ip_count, 0) AS bmcnet_child_ip_count
FROM internet_connections_connections ic FROM internet_connections_connections ic
LEFT JOIN customers c ON c.id = ic.customer_id LEFT JOIN customers c ON c.id = ic.customer_id
LEFT JOIN vendors vendor ON vendor.id = ic.vendor_id
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
LEFT JOIN customers subc ON subc.id = sub.customer_id LEFT JOIN customers subc ON subc.id = sub.customer_id
LEFT JOIN sag_subscriptions sla ON sla.id = ic.sla_subscription_id
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ir.connection_id, ir.connection_id,
@ -919,6 +1108,7 @@ def _build_bmcnet_summary(children: List[Dict[str, Any]]) -> Dict[str, Any]:
class ConnectionCreatePayload(BaseModel): class ConnectionCreatePayload(BaseModel):
name: str name: str
provider: Optional[str] = None provider: Optional[str] = None
vendor_id: Optional[int] = None
customer_id: Optional[int] = None customer_id: Optional[int] = None
parent_id: Optional[int] = None parent_id: Optional[int] = None
address: Optional[str] = None address: Optional[str] = None
@ -939,11 +1129,14 @@ class ConnectionCreatePayload(BaseModel):
value_type: str = "other" value_type: str = "other"
value_label: Optional[str] = None value_label: Optional[str] = None
subscription_id: Optional[int] = None subscription_id: Optional[int] = None
sla_subscription_id: Optional[int] = None
is_manual_shared: bool = False
class ConnectionUpdatePayload(BaseModel): class ConnectionUpdatePayload(BaseModel):
name: Optional[str] = None name: Optional[str] = None
provider: Optional[str] = None provider: Optional[str] = None
vendor_id: Optional[int] = None
customer_id: Optional[int] = None customer_id: Optional[int] = None
parent_id: Optional[int] = None parent_id: Optional[int] = None
address: Optional[str] = None address: Optional[str] = None
@ -964,6 +1157,8 @@ class ConnectionUpdatePayload(BaseModel):
value_type: Optional[str] = None value_type: Optional[str] = None
value_label: Optional[str] = None value_label: Optional[str] = None
subscription_id: Optional[int] = None subscription_id: Optional[int] = None
sla_subscription_id: Optional[int] = None
is_manual_shared: Optional[bool] = None
class PricingCreatePayload(BaseModel): class PricingCreatePayload(BaseModel):
@ -973,6 +1168,22 @@ class PricingCreatePayload(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
def _apply_internet_vendor(normalized: dict) -> dict:
vendor_id = normalized.get("vendor_id")
if not vendor_id:
normalized["vendor_id"] = None
return normalized
vendor = execute_query_single(
"SELECT id, name FROM vendors WHERE id = %s AND is_active = TRUE AND is_internet_provider = TRUE",
(vendor_id,),
)
if not vendor:
raise HTTPException(status_code=409, detail="Den valgte leverandør er ikke markeret som internetleverandør")
normalized["vendor_id"] = int(vendor["id"])
normalized["provider"] = vendor["name"]
return normalized
class IpRangeCreatePayload(BaseModel): class IpRangeCreatePayload(BaseModel):
name: str name: str
cidr: str cidr: str
@ -1308,7 +1519,6 @@ async def list_internet_invoice_sync_runs(
latest.connections_updated, latest.ip_ranges_synced, latest.total_lines, latest.connections_updated, latest.ip_ranges_synced, latest.total_lines,
latest.actionable_lines, latest.skipped_lines, latest.actionable_lines, latest.skipped_lines,
COALESCE(latest.error_message, file.error_message) AS error_message, COALESCE(latest.error_message, file.error_message) AS error_message,
latest.result_json,
COALESCE(latest.processed_at, file.processed_at, key.created_at) AS processed_at, COALESCE(latest.processed_at, file.processed_at, key.created_at) AS processed_at,
legacy.connection_count AS legacy_connection_count, legacy.connection_count AS legacy_connection_count,
COALESCE(review.resolved_lines, 0) AS resolved_lines, COALESCE(review.resolved_lines, 0) AS resolved_lines,
@ -1373,6 +1583,110 @@ async def list_internet_invoice_sync_runs(
} }
@router.get("/internet-connections/invoice-sync-runs/{run_id:int}")
async def get_internet_invoice_sync_run(run_id: int):
run = execute_query_single(
"""
SELECT id, invoice_number, status, skipped_lines, result_json
FROM internet_connections_invoice_sync_runs
WHERE id = %s
""",
(run_id,),
)
if not run:
raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet")
decisions = execute_query(
"""
SELECT line_number, action, connection_id, note, resolved_at
FROM internet_connections_invoice_review_decisions
WHERE run_id = %s
ORDER BY line_number
""",
(run_id,),
) or []
payload = dict(run)
payload["review_decisions"] = [dict(item) for item in decisions]
payload["resolved_lines"] = len(decisions)
payload["unresolved_lines"] = max(int(run.get("skipped_lines") or 0) - len(decisions), 0)
return payload
@router.post("/internet-connections/invoice-sync-runs/reconcile")
async def reconcile_internet_invoice_reviews():
"""Resolve stale IP review lines when the exact range is already allocated."""
runs = execute_query(
"""
SELECT id, result_json
FROM internet_connections_invoice_sync_runs
WHERE status IN ('warning', 'skipped')
ORDER BY processed_at DESC, id DESC
"""
) or []
resolved = 0
completed_runs = 0
for run in runs:
result = run.get("result_json") or {}
skipped_items = result.get("skipped_items") or []
for item in skipped_items:
if item.get("classification") != "ip_range":
continue
cidr = str(item.get("ip_address") or "").strip()
line_number = int(item.get("line_number") or 0)
if not cidr or not line_number:
continue
try:
cidr = str(ipaddress.ip_network(cidr, strict=False))
except ValueError:
continue
existing = execute_query_single(
"""
SELECT ir.connection_id
FROM internet_connections_ip_ranges ir
JOIN internet_connections_connections ic
ON ic.id = ir.connection_id AND ic.deleted_at IS NULL
WHERE ir.deleted_at IS NULL AND HOST(ir.cidr::cidr) = HOST(%s::cidr)
AND MASKLEN(ir.cidr::cidr) = MASKLEN(%s::cidr)
ORDER BY ir.id DESC
LIMIT 1
""",
(cidr, cidr),
)
if not existing:
continue
prior = execute_query_single(
"""
SELECT 1 FROM internet_connections_invoice_review_decisions
WHERE run_id = %s AND line_number = %s
""",
(run["id"], line_number),
)
if prior:
continue
execute_query(
"""
INSERT INTO internet_connections_invoice_review_decisions
(run_id, line_number, action, connection_id, note)
VALUES (%s, %s, 'link_existing', %s, %s)
ON CONFLICT (run_id, line_number) DO NOTHING
""",
(run["id"], line_number, existing["connection_id"], "Automatisk løst: IP-rangen er allerede allokeret."),
fetch=False,
)
resolved += 1
decision_count = execute_query_single(
"SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s",
(run["id"],),
) or {"count": 0}
if skipped_items and int(decision_count.get("count") or 0) >= len(skipped_items):
execute_query(
"UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s",
(run["id"],),
fetch=False,
)
completed_runs += 1
return {"resolved_lines": resolved, "completed_runs": completed_runs}
@router.post("/internet-connections/invoice-sync-runs/{run_id}/review") @router.post("/internet-connections/invoice-sync-runs/{run_id}/review")
async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest): async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest):
if data.action not in {"ignore", "link_existing", "create_separate"}: if data.action not in {"ignore", "link_existing", "create_separate"}:
@ -1495,6 +1809,8 @@ async def list_connections(
value_type: Optional[str] = Query(None), value_type: Optional[str] = Query(None),
shared_only: bool = Query(False), shared_only: bool = Query(False),
bmcnet_only: bool = Query(False), bmcnet_only: bool = Query(False),
unallocated_only: bool = Query(False),
allocated_only: bool = Query(False),
): ):
query = _connection_select_sql() query = _connection_select_sql()
params: list[object] = [] params: list[object] = []
@ -1528,33 +1844,130 @@ async def list_connections(
OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks') OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
) )
""" """
if unallocated_only:
query += " AND ic.customer_id IS NULL"
if allocated_only:
query += " AND ic.customer_id IS NOT NULL"
query += " ORDER BY c.name ASC NULLS LAST, ic.name ASC" query += " ORDER BY c.name ASC NULLS LAST, ic.name ASC"
try: try:
rows = execute_query(query, tuple(params) if params else ()) or [] rows = execute_query(query, tuple(params) if params else ()) or []
except Exception as exc: except Exception as exc:
logger.warning("Failed to load internet connections: %s", exc) logger.exception("Failed to load internet connections")
return [] raise HTTPException(status_code=500, detail="Kunne ikke hente internetforbindelser") from exc
return [_decorate_connection_row(dict(row)) for row in rows] return [_decorate_connection_row(dict(row)) for row in rows]
@router.post("/internet-connections/import/ip-nordic")
async def import_ip_nordic_connections(file: UploadFile = File(...), commit: bool = Form(False)):
filename = str(file.filename or "")
if not filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Vælg en .xlsx-fil fra IP Nordic")
items = _parse_ip_nordic_xlsx(await file.read())
created_count = 0
skipped_count = 0
preview_items: List[Dict[str, Any]] = []
for item in items:
existing = execute_query_single(
"""
SELECT id, name, address
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND LOWER(COALESCE(provider, '')) = LOWER(%s)
AND regexp_replace(
replace(replace(replace(LOWER(COALESCE(address, '')), 'æ', 'ae'), 'ø', 'oe'), 'å', 'aa'),
'[^a-z0-9]', '', 'g'
) = %s
LIMIT 1
""",
("IP Nordic", _normalize_service_location(item["address"])),
)
action = "skip" if existing else "create"
connection_id = int(existing["id"]) if existing else None
if commit and not existing:
notes = (
f"Importeret fra {filename}. Leverandørens firmanr.: {item['company_number']}. "
f"Rapporteret firma: {item['reported_company']}. {item['line_count']} regnearkslinje(r) samlet. "
"Kunde tildeles aldrig automatisk. Kredsløbsnummer og teknologi kræver manuel kontrol."
)
rows = execute_query(
"""
INSERT INTO internet_connections_connections (
name, provider, customer_id, address, status, monthly_cost, sales_price,
technology, connection_type, contract_start, notes,
allocation_model, value_type, value_label
) VALUES (%s, %s, NULL, %s, 'pending', %s, %s, 'Ukendt', 'other', %s, %s,
'dedicated', 'other', 'Internetforbindelse')
RETURNING id
""",
(
f"IP Nordic · {item['address']}", "IP Nordic", item["address"],
item["monthly_cost"], item["sales_price"], item["start_date"], notes,
),
) or []
if not rows:
raise HTTPException(status_code=500, detail=f"Kunne ikke importere {item['address']}")
connection_id = int(rows[0]["id"])
_create_history_entry(
connection_id,
"spreadsheet_connection_created",
"IP Nordic-forbindelse oprettet fra leverandørliste",
{"source_file": filename, "address": item["address"], "customer_auto_assigned": False},
)
created_count += 1
else:
skipped_count += 1 if existing else 0
preview_items.append({
"action": action,
"existing_connection_id": connection_id if existing else None,
"connection_id": connection_id,
"company_number": item["company_number"],
"reported_company": item["reported_company"],
"address": item["address"],
"start_date": item["start_date"].isoformat() if item["start_date"] else None,
"sales_price": float(item["sales_price"]),
"monthly_cost": float(item["monthly_cost"]),
"line_count": item["line_count"],
})
return {
"committed": commit,
"provider": "IP Nordic",
"items": preview_items,
"total": len(preview_items),
"create_count": sum(1 for item in preview_items if item["action"] == "create"),
"existing_count": sum(1 for item in preview_items if item["action"] == "skip"),
"created_count": created_count,
"skipped_count": skipped_count,
"customer_auto_assignment": False,
}
@router.post("/internet-connections", response_model=dict) @router.post("/internet-connections", response_model=dict)
async def create_connection(payload: ConnectionCreatePayload): async def create_connection(payload: ConnectionCreatePayload):
normalized = _normalize_connection_payload(payload.model_dump()) normalized = _normalize_connection_payload(payload.model_dump())
normalized = _apply_internet_vendor(normalized)
if normalized.get("allocation_model") == "shared" and normalized.get("value_type") == "delefiber" and not normalized.get("parent_id"):
normalized["is_manual_shared"] = True
try: try:
rows = execute_query( rows = execute_query(
""" """
INSERT INTO internet_connections_connections ( INSERT INTO internet_connections_connections (
parent_id, name, provider, customer_id, address, status, monthly_cost, sales_price, technology, parent_id, name, provider, vendor_id, customer_id, address, status, monthly_cost, sales_price, technology,
connection_type, circuit_number, speed_mbps, upload_mbps, download_mbps, monitoring_url, connection_type, circuit_number, speed_mbps, upload_mbps, download_mbps, monitoring_url,
contract_start, contract_end, notes, allocation_model, value_type, value_label, subscription_id contract_start, contract_end, notes, allocation_model, value_type, value_label, subscription_id,
sla_subscription_id, is_manual_shared
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
( (
normalized.get("parent_id"), normalized.get("parent_id"),
normalized.get("name"), normalized.get("name"),
normalized.get("provider"), normalized.get("provider"),
normalized.get("vendor_id"),
normalized.get("customer_id"), normalized.get("customer_id"),
normalized.get("address"), normalized.get("address"),
normalized.get("status"), normalized.get("status"),
@ -1574,6 +1987,8 @@ async def create_connection(payload: ConnectionCreatePayload):
normalized.get("value_type"), normalized.get("value_type"),
normalized.get("value_label"), normalized.get("value_label"),
normalized.get("subscription_id"), normalized.get("subscription_id"),
normalized.get("sla_subscription_id"),
normalized.get("is_manual_shared", False),
), ),
) )
except HTTPException: except HTTPException:
@ -1598,6 +2013,7 @@ async def create_connection(payload: ConnectionCreatePayload):
"subscription_id": normalized.get("subscription_id"), "subscription_id": normalized.get("subscription_id"),
}, },
) )
_sync_bmcnet_parent_classification(normalized.get("parent_id"))
return connection return connection
@ -1623,11 +2039,47 @@ async def update_connection(connection_id: int, payload: ConnectionUpdatePayload
): ):
merged_values["value_label"] = "Mangler klassifikation" merged_values["value_label"] = "Mangler klassifikation"
normalized = _normalize_connection_payload(merged_values) normalized = _normalize_connection_payload(merged_values)
normalized = _apply_internet_vendor(normalized)
if normalized.get("sla_subscription_id"):
sla = execute_query_single(
"""
SELECT id, customer_id, product_name, status
FROM sag_subscriptions WHERE id = %s
""",
(normalized["sla_subscription_id"],),
)
if not sla:
raise HTTPException(status_code=404, detail="SLA-aftalen blev ikke fundet")
if "sla" not in str(sla.get("product_name") or "").lower():
raise HTTPException(status_code=409, detail="Det valgte abonnement er ikke en SLA-aftale")
if normalized.get("customer_id") and int(sla["customer_id"]) != int(normalized["customer_id"]):
raise HTTPException(status_code=409, detail="SLA-aftalen tilhører en anden kunde")
if normalized.get("value_type") == "subscription":
subscription = execute_query_single(
"SELECT id, customer_id FROM sag_subscriptions WHERE id = %s",
(normalized.get("subscription_id"),),
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet blev ikke fundet")
if normalized.get("customer_id") and subscription.get("customer_id") and int(normalized["customer_id"]) != int(subscription["customer_id"]):
raise HTTPException(status_code=409, detail="Abonnementet tilhører en anden kunde")
already_linked = execute_query_single(
"""
SELECT id FROM internet_connections_connections
WHERE subscription_id = %s AND id <> %s AND deleted_at IS NULL
LIMIT 1
""",
(normalized.get("subscription_id"), connection_id),
)
if already_linked:
raise HTTPException(status_code=409, detail=f"Abonnementet er allerede koblet til forbindelse #{already_linked['id']}")
set_parts = [] set_parts = []
params: list[object] = [] params: list[object] = []
changed: dict[str, object] = {} changed: dict[str, object] = {}
allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "subscription_id"} allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "subscription_id", "sla_subscription_id"}
for field in allowed_fields: for field in allowed_fields:
value = normalized.get(field) value = normalized.get(field)
set_parts.append(f"{field} = %s") set_parts.append(f"{field} = %s")
@ -1661,6 +2113,12 @@ async def update_connection(connection_id: int, payload: ConnectionUpdatePayload
f"Opdaterede forbindelse {rows[0].get('name')}", f"Opdaterede forbindelse {rows[0].get('name')}",
changed, changed,
) )
previous_parent_id = existing.get("parent_id")
current_parent_id = normalized.get("parent_id")
_sync_bmcnet_parent_classification(previous_parent_id)
if current_parent_id != previous_parent_id:
_sync_bmcnet_parent_classification(current_parent_id)
_sync_bmcnet_parent_classification(connection_id)
return dict(rows[0]) return dict(rows[0])
@ -1860,7 +2318,11 @@ async def pricing_summary():
@router.get("/internet-connections/subscription-options", response_model=List[dict]) @router.get("/internet-connections/subscription-options", response_model=List[dict])
async def subscription_options(q: Optional[str] = Query(None), status: str = Query("active")): async def subscription_options(
q: Optional[str] = Query(None),
status: str = Query("active"),
customer_id: Optional[int] = Query(None),
):
params: List[Any] = [] params: List[Any] = []
where = ["1=1"] where = ["1=1"]
if status and status != "all": if status and status != "all":
@ -1870,6 +2332,9 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que
term = f"%{q}%" term = f"%{q}%"
where.append("(s.subscription_number ILIKE %s OR s.product_name ILIKE %s OR c.name ILIKE %s)") where.append("(s.subscription_number ILIKE %s OR s.product_name ILIKE %s OR c.name ILIKE %s)")
params.extend([term, term, term]) params.extend([term, term, term])
if customer_id:
where.append("s.customer_id = %s")
params.append(customer_id)
rows = execute_query( rows = execute_query(
f""" f"""
SELECT SELECT
@ -1878,7 +2343,8 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que
s.product_name, s.product_name,
s.customer_id, s.customer_id,
c.name AS customer_name, c.name AS customer_name,
s.status s.status,
s.price
FROM sag_subscriptions s FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
WHERE {" AND ".join(where)} WHERE {" AND ".join(where)}
@ -1890,6 +2356,160 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que
return [dict(row) for row in rows] return [dict(row) for row in rows]
@router.get("/internet-connections/{connection_id:int}/allocation-suggestions")
async def connection_allocation_suggestions(connection_id: int):
connection = execute_query_single(
"""
SELECT id, address, customer_id
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(connection_id,),
)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
if connection.get("customer_id"):
return {"connection_id": connection_id, "address": connection.get("address"), "items": []}
target_address = str(connection.get("address") or "").strip()
target_normalized = _normalize_service_location(target_address)
target_components = _address_match_components(target_address)
rows = execute_query(
"""
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(c.address), ''),
NULLIF(TRIM(CONCAT_WS(' ', c.postal_code, c.city)), '')) AS candidate_address,
'customer' AS address_source, NULL::text AS location_name
FROM customers c
WHERE c.deleted_at IS NULL AND COALESCE(c.is_active, TRUE) = TRUE
UNION ALL
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(l.address_street), ''),
NULLIF(TRIM(CONCAT_WS(' ', l.address_postal_code, l.address_city)), '')) AS candidate_address,
'location' AS address_source, l.name AS location_name
FROM locations_locations l
JOIN customers c ON c.id = l.customer_id AND c.deleted_at IS NULL
WHERE l.deleted_at IS NULL AND COALESCE(l.is_active, TRUE) = TRUE
""",
(),
) or []
suggestions: Dict[int, Dict[str, Any]] = {}
for row in rows:
candidate_address = str(row.get("candidate_address") or "").strip()
candidate_normalized = _normalize_service_location(candidate_address)
if not candidate_normalized:
continue
candidate_components = _address_match_components(candidate_address)
score = 100 if candidate_normalized == target_normalized else 0
if (
not score
and target_components["postal_code"]
and candidate_components["postal_code"] == target_components["postal_code"]
and candidate_components["street_name"] == target_components["street_name"]
):
score = 90
target_numbers = target_components["house_numbers"]
candidate_numbers = candidate_components["house_numbers"]
if target_numbers and candidate_numbers:
target_number = target_numbers[0]
if target_number not in candidate_numbers and not (
len(candidate_numbers) >= 2 and min(candidate_numbers) <= target_number <= max(candidate_numbers)
):
score = 0
if score < 90:
continue
customer_id = int(row["customer_id"])
existing = suggestions.get(customer_id)
candidate = {
"customer_id": customer_id,
"customer_name": row.get("customer_name"),
"address": candidate_address,
"address_source": row.get("address_source"),
"location_name": row.get("location_name"),
"match_score": score,
}
if not existing or score > int(existing.get("match_score") or 0):
suggestions[customer_id] = candidate
items = sorted(suggestions.values(), key=lambda item: (-item["match_score"], str(item["customer_name"] or "").lower()))
return {"connection_id": connection_id, "address": target_address, "items": items}
@router.get("/internet-connections/allocation-overview")
async def internet_connection_allocation_overview():
"""Return compact address suggestions for the unallocated work queue."""
connections = execute_query(
"""
SELECT id, address
FROM internet_connections_connections
WHERE deleted_at IS NULL AND customer_id IS NULL
AND NOT (parent_id IS NULL AND allocation_model = 'shared' AND value_type = 'delefiber')
ORDER BY address, id
"""
) or []
candidate_rows = execute_query(
"""
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(c.address), ''),
NULLIF(TRIM(CONCAT_WS(' ', c.postal_code, c.city)), '')) AS candidate_address,
'customer' AS address_source, NULL::text AS location_name
FROM customers c
WHERE c.deleted_at IS NULL AND COALESCE(c.is_active, TRUE) = TRUE
UNION ALL
SELECT c.id AS customer_id, c.name AS customer_name,
CONCAT_WS(', ', NULLIF(TRIM(l.address_street), ''),
NULLIF(TRIM(CONCAT_WS(' ', l.address_postal_code, l.address_city)), '')) AS candidate_address,
'location' AS address_source, l.name AS location_name
FROM locations_locations l
JOIN customers c ON c.id = l.customer_id AND c.deleted_at IS NULL
WHERE l.deleted_at IS NULL AND COALESCE(l.is_active, TRUE) = TRUE
"""
) or []
items = []
for connection in connections:
target_address = str(connection.get("address") or "").strip()
target_normalized = _normalize_service_location(target_address)
target_components = _address_match_components(target_address)
matches: Dict[int, Dict[str, Any]] = {}
for row in candidate_rows:
candidate_address = str(row.get("candidate_address") or "").strip()
candidate_normalized = _normalize_service_location(candidate_address)
if not target_normalized or not candidate_normalized:
continue
candidate_components = _address_match_components(candidate_address)
score = 100 if candidate_normalized == target_normalized else 0
if (
not score and target_components["postal_code"]
and candidate_components["postal_code"] == target_components["postal_code"]
and candidate_components["street_name"] == target_components["street_name"]
):
score = 90
target_numbers = target_components["house_numbers"]
candidate_numbers = candidate_components["house_numbers"]
if target_numbers and candidate_numbers and target_numbers[0] not in candidate_numbers and not (
len(candidate_numbers) >= 2 and min(candidate_numbers) <= target_numbers[0] <= max(candidate_numbers)
):
score = 0
if score < 90:
continue
customer_id = int(row["customer_id"])
candidate = {
"customer_id": customer_id, "customer_name": row.get("customer_name"),
"address": candidate_address, "address_source": row.get("address_source"),
"location_name": row.get("location_name"), "match_score": score,
}
if customer_id not in matches or score > int(matches[customer_id].get("match_score") or 0):
matches[customer_id] = candidate
candidates = sorted(matches.values(), key=lambda item: (-item["match_score"], str(item["customer_name"] or "").lower()))
items.append({
"connection_id": int(connection["id"]),
"address": connection.get("address"),
"suggestions": candidates[:5],
"unique_suggestion": candidates[0] if len(candidates) == 1 else None,
})
return {"items": items}
@router.get("/internet-connections/subscriptions/{subscription_id}/provisioning") @router.get("/internet-connections/subscriptions/{subscription_id}/provisioning")
async def get_subscription_provisioning(subscription_id: int): async def get_subscription_provisioning(subscription_id: int):
subscription = _load_subscription(subscription_id) subscription = _load_subscription(subscription_id)
@ -1942,7 +2562,7 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
raise HTTPException(status_code=409, detail="Du skal vaelge et ledigt IP-range til hver IP-produktlinje") raise HTTPException(status_code=409, detail="Du skal vaelge et ledigt IP-range til hver IP-produktlinje")
shared_connection = execute_query_single( shared_connection = execute_query_single(
_connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "), _connection_select_sql(" AND ic.id = %s AND ic.parent_id IS NULL "),
(payload.shared_connection_id,), (payload.shared_connection_id,),
) )
if not shared_connection: if not shared_connection:
@ -2289,6 +2909,10 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
conn.commit() conn.commit()
_sync_bmcnet_parent_classification(previous_parent_id)
if int(previous_parent_id) != int(payload.shared_connection_id):
_sync_bmcnet_parent_classification(payload.shared_connection_id)
_create_history_entry( _create_history_entry(
existing_connection_id, existing_connection_id,
"subscription_provisioned", "subscription_provisioned",
@ -2326,11 +2950,11 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
@router.post("/internet-connections/{connection_id}/bmcnet-connections") @router.post("/internet-connections/{connection_id}/bmcnet-connections")
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload): async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
head_row = execute_query_single( head_row = execute_query_single(
_connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "), _connection_select_sql(" AND ic.id = %s AND ic.parent_id IS NULL "),
(connection_id,), (connection_id,),
) )
if not head_row: if not head_row:
raise HTTPException(status_code=404, detail="Delt hovedforbindelse blev ikke fundet") raise HTTPException(status_code=404, detail="Hovedforbindelsen blev ikke fundet")
head = _decorate_connection_row(dict(head_row)) head = _decorate_connection_row(dict(head_row))
customer = execute_query_single( customer = execute_query_single(
@ -2627,8 +3251,8 @@ async def list_ip_ranges(connection_id: int):
(connection_id,), (connection_id,),
) or [] ) or []
except Exception as exc: except Exception as exc:
logger.warning("Failed to load IP ranges: %s", exc) logger.exception("Failed to load IP ranges for connection %s", connection_id)
return [] raise HTTPException(status_code=500, detail="Kunne ikke hente IP-ranges") from exc
address_map: dict[int, list[dict]] = {} address_map: dict[int, list[dict]] = {}
for address in address_rows: for address in address_rows:

View File

@ -12,14 +12,30 @@
border-radius: 24px; border-radius: 24px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08); box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
position: relative;
overflow: hidden;
} }
.detail-hero::after { content:""; position:absolute; width:190px; height:190px; right:-65px; bottom:-95px; border-radius:50%; border:28px solid rgba(15,76,117,.06); pointer-events:none; }
.detail-title-wrap { display:flex; align-items:flex-start; gap:1rem; position:relative; z-index:1; }
.detail-title-icon { width:52px; height:52px; flex:0 0 52px; display:grid; place-items:center; border-radius:16px; color:#fff; background:linear-gradient(135deg,#0f4c75,#3282b8); box-shadow:0 9px 20px rgba(15,76,117,.22); font-size:1.35rem; }
.detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; }
.detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; }
.detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; }
.detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); }
.detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; }
.detail-metrics-grid > [class*="col-"] { width:auto; padding:0; }
.detail-panel { .detail-panel {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12); border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px; border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06); box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
} }
.allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); }
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
.allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; }
.detail-metric { .detail-metric {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01)); background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
@ -27,7 +43,10 @@
border-radius: 16px; border-radius: 16px;
padding: 1rem; padding: 1rem;
height: 100%; height: 100%;
position:relative;
overflow:hidden;
} }
.detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); }
.detail-metric-label { .detail-metric-label {
color: var(--text-secondary); color: var(--text-secondary);
@ -55,6 +74,31 @@
padding: 0.85rem; padding: 0.85rem;
} }
#ipRangesList { grid-template-columns: 1fr; }
.ip-range-card {
border-left:4px solid #3282b8;
background:linear-gradient(135deg,rgba(50,130,184,.07),rgba(255,255,255,.45));
display:grid;
grid-template-columns:minmax(250px,1.35fr) repeat(3,minmax(115px,.65fr)) auto;
align-items:center;
gap:1rem;
padding:1rem 1.1rem;
}
.ip-range-cidr { display:inline-flex; align-items:center; gap:.42rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.03rem; }
.ip-range-main,.ip-range-meta { min-width:0; }
.ip-range-meta .label { margin-bottom:.15rem; }
.ip-range-meta .value { font-size:.92rem; overflow-wrap:anywhere; }
.ip-range-actions { justify-self:end; }
.ip-range-edit { grid-column:1/-1; border-top:1px solid rgba(15,76,117,.1); padding-top:.9rem; }
.ip-range-warning { grid-column:1/-1; display:flex; align-items:flex-start; gap:.75rem; padding:.85rem 1rem; border:1px solid #f4cccc; border-radius:12px; background:#fff8f8; }
@media (max-width: 1000px) {
.ip-range-card { grid-template-columns:repeat(2,minmax(0,1fr)); }
.ip-range-main,.ip-range-actions,.ip-range-edit { grid-column:1/-1; }
.ip-range-actions { justify-self:start; }
}
.ip-empty-state { text-align:center; padding:2.5rem 1rem; border:1px dashed rgba(15,76,117,.22); border-radius:16px; background:rgba(15,76,117,.025); }
.ip-empty-state i { display:block; color:var(--accent); opacity:.55; font-size:2rem; margin-bottom:.55rem; }
.detail-grid-card.editing { .detail-grid-card.editing {
border-color: rgba(15, 76, 117, 0.28); border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.06); background: rgba(15, 76, 117, 0.06);
@ -235,6 +279,7 @@
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.detail-read-grid { .detail-read-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -245,6 +290,8 @@
gap: 0.2rem; gap: 0.2rem;
} }
} }
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
</style> </style>
{% endblock %} {% endblock %}
@ -252,12 +299,16 @@
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="detail-hero mb-4"> <div class="detail-hero mb-4">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-start gap-3"> <div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-start gap-3">
<div class="detail-title-wrap">
<div class="detail-title-icon"><i class="bi bi-router"></i></div>
<div> <div>
<div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div> <div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div>
<h2 class="h3 mb-1" id="detailName">Indlæser...</h2> <h2 class="h3 mb-1" id="detailName">Indlæser...</h2>
<div class="text-muted" id="detailSubtitle">Henter forbindelsesdata...</div> <div class="text-muted" id="detailSubtitle">Henter forbindelsesdata...</div>
<div class="detail-circuit-badge" id="detailCircuitBadge"><i class="bi bi-diagram-3"></i><span>Henter kredsløb...</span></div>
<div class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div> <div class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div>
</div> </div>
</div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<a href="/economy/internet-connections" class="btn btn-outline-secondary"> <a href="/economy/internet-connections" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Tilbage <i class="bi bi-arrow-left me-1"></i>Tilbage
@ -276,9 +327,45 @@
</button> </button>
</div> </div>
</div> </div>
<nav class="detail-section-nav" aria-label="Sektioner">
<a href="#connection-core"><i class="bi bi-info-circle"></i>Grunddata</a>
<a href="#connection-ip"><i class="bi bi-hdd-network"></i>IP-ranges og adresser</a>
<a href="#connection-pricing"><i class="bi bi-cash-coin"></i>Pris og kontrakt</a>
<a href="#connection-history"><i class="bi bi-clock-history"></i>Historik</a>
</nav>
</div> </div>
<div class="row g-4 mb-4"> <div class="allocation-banner p-4 mb-4 d-none" id="allocationBanner">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
<div>
<div class="fw-bold text-warning-emphasis"><i class="bi bi-exclamation-circle-fill me-2"></i>Forbindelsen er ikke tildelt en kunde</div>
<div class="small text-muted mt-1">Vælg virksomheden på installationsadressen. Intet bliver tildelt automatisk.</div>
<div class="d-flex flex-wrap gap-2 mt-3" id="allocationSuggestions"></div>
</div>
<div style="min-width:min(100%,420px);" class="vstack gap-2">
<select class="form-select" id="allocationCustomerSelect" onchange="loadAllocationSubscriptions()"><option value="">Vælg kunde…</option></select>
<select class="form-select" id="allocationSubscriptionSelect" disabled><option value="">Kun tildel kunde intet abonnement</option></select>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-warning" id="saveAllocationBtn" type="button" onclick="saveConnectionAllocation()">Tildel kunde</button>
<a class="btn btn-outline-secondary d-none" id="allocationCustomerLink" href="#">Åbn kunde</a>
</div>
<div class="small" id="allocationFeedback" role="status"></div>
</div>
</div>
</div>
<div class="alert mb-4" id="slaBanner" role="status">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
<div id="slaBannerContent"></div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<select class="form-select" id="slaSubscriptionSelect" style="min-width:300px"></select>
<button class="btn btn-primary" id="saveSlaBtn" type="button" onclick="saveSlaAllocation()">Gem SLA</button>
</div>
</div>
<div class="small mt-2" id="slaFeedback"></div>
</div>
<div class="detail-metrics-grid mb-4">
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="detail-metric"> <div class="detail-metric">
<div class="detail-metric-label">Salgspris</div> <div class="detail-metric-label">Salgspris</div>
@ -314,7 +401,7 @@
<div class="row g-4"> <div class="row g-4">
<div class="col-xl-8"> <div class="col-xl-8">
<div class="detail-panel p-4 mb-4"> <div class="detail-panel p-4 mb-4" id="connection-core">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Grunddata</h5> <h5 class="mb-0">Grunddata</h5>
<span id="detailStatusBadge" class="status-pill inactive">-</span> <span id="detailStatusBadge" class="status-pill inactive">-</span>
@ -327,7 +414,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Leverandør</label> <label class="form-label">Leverandør</label>
<input type="text" class="form-control" id="fieldProvider"> <select class="form-select" id="fieldVendorId"><option value="">Ingen valgt</option></select>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Kredsløb</label> <label class="form-label">Kredsløb</label>
@ -383,6 +470,13 @@
<label class="form-label">Overvågning</label> <label class="form-label">Overvågning</label>
<input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://..."> <input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://...">
</div> </div>
<div class="col-12">
<div class="form-check form-switch border rounded-3 p-3 ps-5 bg-light">
<input class="form-check-input" type="checkbox" role="switch" id="fieldManualShared" onchange="toggleManualDelefiber()">
<label class="form-check-label fw-semibold" for="fieldManualShared">BMC Delefiber</label>
<div class="small text-muted">BMC ejer hovedforbindelsen og udstyret og kan dele den ud til flere BMCnet-kunder.</div>
</div>
</div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Allokering</label> <label class="form-label">Allokering</label>
<select class="form-select" id="fieldAllocationModel"> <select class="form-select" id="fieldAllocationModel">
@ -424,7 +518,7 @@
</div> </div>
</div> </div>
<div class="detail-panel p-4 mb-4"> <div class="detail-panel p-4 mb-4" id="connection-ip">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h5 class="mb-0">IP-ranges</h5> <h5 class="mb-0">IP-ranges</h5>
@ -538,16 +632,16 @@
</div> </div>
</div> </div>
<div class="detail-panel p-4 mb-4"> <div class="detail-panel p-4 mb-4" id="connection-pricing">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Pris og kontrakt</h5> <h5 class="mb-0">Pris og kontrakt</h5>
<div class="small text-muted" id="pricingFeedback"></div> <div class="small text-muted" id="pricingFeedback"></div>
</div> </div>
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-3"><input type="date" class="form-control" id="pricingEffectiveDateInput"></div> <div class="col-md-3"><label class="form-label" for="pricingEffectiveDateInput">Gældende fra</label><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></div> <div class="col-md-3"><label class="form-label" for="pricingPurchaseInput">Indkøbspris</label><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="0 kr." step="0.01"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></div> <div class="col-md-3"><label class="form-label" for="pricingSalesInput">Salgspris</label><input type="number" class="form-control" id="pricingSalesInput" placeholder="0 kr." step="0.01"></div>
<div class="col-md-3"><input type="text" class="form-control" id="pricingNotesInput" placeholder="Note"></div> <div class="col-md-3"><label class="form-label" for="pricingNotesInput">Note</label><input type="text" class="form-control" id="pricingNotesInput" placeholder="Valgfri note"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button> <button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button>
@ -580,7 +674,7 @@
</div> </div>
</div> </div>
<div class="detail-panel p-4"> <div class="detail-panel p-4" id="connection-history">
<h5 class="mb-3">Historik</h5> <h5 class="mb-3">Historik</h5>
<div id="historyList"></div> <div id="historyList"></div>
</div> </div>
@ -832,6 +926,13 @@
} }
} }
function showDetailMessage(message, isError = false) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = `small ${isError ? 'text-danger' : 'text-success'}`;
feedback.textContent = message;
feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
async function extractErrorMessage(response, fallback) { async function extractErrorMessage(response, fallback) {
try { try {
const payload = await response.clone().json(); const payload = await response.clone().json();
@ -1000,11 +1101,12 @@
} }
async function loadLookups() { async function loadLookups() {
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse] = await Promise.all([ const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([
fetch('/api/v1/customers?limit=1000&is_active=true'), fetch('/api/v1/customers?limit=1000&is_active=true'),
fetch('/api/v1/internet-connections'), fetch('/api/v1/internet-connections'),
fetch('/api/v1/internet-connections/subscription-options?status=active'), fetch('/api/v1/internet-connections/subscription-options?status=active'),
fetch('/api/v1/products'), fetch('/api/v1/products'),
fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100'),
]); ]);
const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] }; const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] };
@ -1012,6 +1114,9 @@
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : []; connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : []; subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : [];
bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : []; bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : [];
const internetVendors = vendorsResponse.ok ? await vendorsResponse.json() : [];
document.getElementById('fieldVendorId').innerHTML = '<option value="">Ingen valgt</option>' + internetVendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
populateDatalist('customerLookupList', customerOptions); populateDatalist('customerLookupList', customerOptions);
populateDatalist( populateDatalist(
@ -1058,7 +1163,7 @@
} }
function getSharedHeadOptions() { function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => item?.is_shared_head); return (connectionOptions || []).filter((item) => !item?.parent_id);
} }
async function loadRangesForSharedHead(sharedHeadId) { async function loadRangesForSharedHead(sharedHeadId) {
@ -1169,8 +1274,9 @@
} }
function populateBmcnetWizard(connection) { function populateBmcnetWizard(connection) {
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head); const canCreateBmcnet = Boolean(connection && !connection.parent_id);
if (!connection?.is_shared_head) return; document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return;
document.getElementById('bmcnetWizardCustomerLookup').value = ''; document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = ''; document.getElementById('bmcnetWizardCustomerId').value = '';
@ -1197,7 +1303,7 @@
} }
function openBmcnetWizard() { function openBmcnetWizard() {
if (!currentConnection?.is_shared_head) return; if (!currentConnection || currentConnection.parent_id) return;
populateBmcnetWizard(currentConnection); populateBmcnetWizard(currentConnection);
if (!bmcnetWizardModal) { if (!bmcnetWizardModal) {
bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal')); bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal'));
@ -1308,6 +1414,111 @@
} }
} }
async function renderAllocationBanner(connection) {
const banner = document.getElementById('allocationBanner');
const customerSelect = document.getElementById('allocationCustomerSelect');
const suggestionsWrap = document.getElementById('allocationSuggestions');
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null
&& connection.allocation_model === 'shared'
&& connection.value_type === 'delefiber')
);
const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber;
banner.classList.toggle('d-none', !shouldSuggestCustomer);
if (!shouldSuggestCustomer) return;
const payload = await safeJson(
await fetch(`/api/v1/internet-connections/${connectionId}/allocation-suggestions`),
{ items: [] }
);
const suggestions = Array.isArray(payload.items) ? payload.items : [];
const suggestedIds = new Set(suggestions.map((item) => Number(item.customer_id)));
const suggestedOptions = suggestions.map((item) => (
`<option value="${item.customer_id}">${escapeHtml(item.customer_name || '-')} · ${escapeHtml(item.address || '')}</option>`
)).join('');
const otherOptions = customerOptions
.filter((item) => !suggestedIds.has(Number(item.id)))
.map((item) => `<option value="${item.id}">${escapeHtml(item.name || '-')}</option>`)
.join('');
customerSelect.innerHTML = '<option value="">Vælg kunde…</option>'
+ (suggestedOptions ? `<optgroup label="Forslag fra adressen">${suggestedOptions}</optgroup>` : '')
+ `<optgroup label="Alle kunder">${otherOptions}</optgroup>`;
suggestionsWrap.innerHTML = suggestions.length
? suggestions.map((item) => `
<button class="allocation-suggestion text-start" type="button" onclick="selectAllocationCustomer(${item.customer_id})">
<span class="fw-semibold d-block">${escapeHtml(item.customer_name || '-')}</span>
<span class="small text-muted">${escapeHtml(item.address || '')}${item.location_name ? ` · ${escapeHtml(item.location_name)}` : ''}</span>
</button>`).join('')
: '<span class="small text-muted">Ingen sikre kundematch på adressen. Vælg manuelt i listen.</span>';
document.getElementById('allocationFeedback').textContent = suggestions.length > 1
? `${suggestions.length} virksomheder er registreret på adressen. Vælg den rigtige.`
: suggestions.length === 1 ? 'Én virksomhed matcher adressen.' : '';
}
async function selectAllocationCustomer(customerId) {
document.getElementById('allocationCustomerSelect').value = String(customerId);
await loadAllocationSubscriptions();
}
async function loadAllocationSubscriptions() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const select = document.getElementById('allocationSubscriptionSelect');
const customerLink = document.getElementById('allocationCustomerLink');
customerLink.classList.toggle('d-none', !customerId);
customerLink.href = customerId ? `/customers/${customerId}` : '#';
if (!customerId) {
select.disabled = true;
select.innerHTML = '<option value="">Kun tildel kunde intet abonnement</option>';
return;
}
select.disabled = true;
select.innerHTML = '<option value="">Henter abonnementer…</option>';
const subscriptions = await safeJson(
await fetch(`/api/v1/internet-connections/subscription-options?customer_id=${customerId}&status=active`),
[]
);
select.innerHTML = '<option value="">Kun tildel kunde intet abonnement</option>'
+ subscriptions.map((item) => `<option value="${item.id}">${escapeHtml(item.subscription_number || `#${item.id}`)} · ${escapeHtml(item.product_name || '-')}</option>`).join('');
select.disabled = false;
document.getElementById('saveAllocationBtn').textContent = subscriptions.length
? 'Tildel kunde / abonnement'
: 'Tildel kunde';
}
async function saveConnectionAllocation() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const subscriptionId = Number(document.getElementById('allocationSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('allocationFeedback');
const button = document.getElementById('saveAllocationBtn');
if (!customerId) {
feedback.className = 'small text-danger';
feedback.textContent = 'Vælg en kunde først.';
return;
}
const payload = { customer_id: customerId };
if (subscriptionId) {
payload.subscription_id = subscriptionId;
payload.value_type = 'subscription';
payload.value_label = null;
}
button.disabled = true;
feedback.className = 'small text-muted';
feedback.textContent = 'Gemmer tildelingen…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Tildelingen kunne ikke gemmes.'));
await loadConnection();
} catch (error) {
feedback.className = 'small text-danger';
feedback.textContent = error.message || 'Tildelingen kunne ikke gemmes.';
} finally {
button.disabled = false;
}
}
async function loadConnection() { async function loadConnection() {
await loadLookups(); await loadLookups();
@ -1340,15 +1551,26 @@
} }
const connection = await connectionResponse.json(); const connection = await connectionResponse.json();
const ranges = await rangesResponse.json(); const ranges = await safeJson(rangesResponse, []);
const addresses = await addressesResponse.json(); const addresses = await safeJson(addressesResponse, []);
const summary = await summaryResponse.json(); const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 });
const pricing = await pricingResponse.json(); const pricing = await safeJson(pricingResponse, {});
const pricingHistory = await pricingHistoryResponse.json(); const pricingHistory = await safeJson(pricingHistoryResponse, []);
const contracts = await contractsResponse.json(); const contracts = await safeJson(contractsResponse, []);
const history = await historyResponse.json(); const history = await safeJson(historyResponse, []);
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} }); const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
const failedSections = [
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
].filter(([response]) => !response.ok).map(([, label]) => label);
if (failedSections.length) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = 'small text-danger';
feedback.textContent = `Kunne ikke hente: ${failedSections.join(', ')}.`;
}
currentConnection = connection; currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : []; currentAddresses = Array.isArray(addresses) ? addresses : [];
currentRanges = Array.isArray(ranges) ? ranges : []; currentRanges = Array.isArray(ranges) ? ranges : [];
@ -1356,6 +1578,8 @@
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), []) ? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
: []; : [];
await renderAllocationBanner(connection);
renderCore(connection, pricing, summary); renderCore(connection, pricing, summary);
renderRanges(currentRanges); renderRanges(currentRanges);
renderAddresses(currentAddresses); renderAddresses(currentAddresses);
@ -1406,6 +1630,7 @@
function renderCore(connection, pricing, summary) { function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse'; document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer';
const ownerText = connection.customer_name || 'Ingen kunde koblet på'; const ownerText = connection.customer_name || 'Ingen kunde koblet på';
const businessText = connection.allocation_model === 'shared' const businessText = connection.allocation_model === 'shared'
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er` ? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
@ -1420,19 +1645,23 @@
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : []; const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
const totalDown = Number(connection.download_mbps || 0); const totalDown = Number(connection.download_mbps || 0);
const totalUp = Number(connection.upload_mbps || 0); const totalUp = Number(connection.upload_mbps || 0);
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0); // Only explicit child allocations count as consumed capacity. A nominal
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0); // connection speed is not proof that bandwidth was allocated to a customer.
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || 0), 0);
const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0; const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0;
const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0; const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`; document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
document.getElementById('metricSales').textContent = formatDKK(effectiveSales); document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase); document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin); document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0)); document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`; document.getElementById('metricBandwidth').textContent = childConnections.length
document.getElementById('metricBandwidthSub').textContent = totalUp ? `${usedDown} Mbps allokeret`
? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op` : '0 Mbps allokeret';
: `${downPct}%`; document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp
? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}`
: 'Kapacitet ikke registreret';
document.getElementById('ipAddressSummary').innerHTML = `Tilgængelige: <strong>${summary.available || 0}</strong> · Reserverede: <strong>${summary.reserved || 0}</strong> · I brug: <strong>${summary.in_use || 0}</strong>`; document.getElementById('ipAddressSummary').innerHTML = `Tilgængelige: <strong>${summary.available || 0}</strong> · Reserverede: <strong>${summary.reserved || 0}</strong> · I brug: <strong>${summary.in_use || 0}</strong>`;
const contractText = connection.contract_start || connection.contract_end const contractText = connection.contract_start || connection.contract_end
@ -1445,32 +1674,35 @@
? `<a href="${escapeHtml(connection.monitoring_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(connection.monitoring_url)}</a>` ? `<a href="${escapeHtml(connection.monitoring_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(connection.monitoring_url)}</a>`
: '<span class="detail-read-muted">-</span>'; : '<span class="detail-read-muted">-</span>';
renderSla(connection);
document.getElementById('coreReadView').innerHTML = ` document.getElementById('coreReadView').innerHTML = `
<div class="detail-read-strip"> <div class="detail-read-strip">
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${connection.provider || '-'}</span></div> <div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${escapeHtml(connection.provider || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${connection.circuit_number || '-'}</span></div> <div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${escapeHtml(connection.circuit_number || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Binding</span><span class="value">${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}</span></div> ${connection.is_shared_head ? `<div class="detail-read-chip"><span class="label">Netværksmodel</span><span class="value">Delt hovedforbindelse${connection.value_type_label ? ` · ${escapeHtml(connection.value_type_label)}` : ''}</span></div>` : ''}
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${connection.connection_type || '-'}</span></div> <div class="detail-read-chip"><span class="label">Type</span><span class="value">${escapeHtml(connection.connection_type || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${connection.technology || '-'}</span></div> <div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${escapeHtml(connection.technology || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Hastighed</span><span class="value">${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps</span></div> <div class="detail-read-chip"><span class="label">Hastighed</span><span class="value">${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps</span></div>
</div> </div>
<div class="detail-read-grid"> <div class="detail-read-grid">
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${connection.name || '-'}</div></div> <div class="detail-read-row full"><span class="label">Navn</span><div class="value">${escapeHtml(connection.name || '-')}</div></div>
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${connection.address || '-'}</div></div> <div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${escapeHtml(connection.address || '-')}</div></div>
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${connection.customer_name || '-'}</div></div> <div class="detail-read-row"><span class="label">Kunde</span><div class="value">${escapeHtml(connection.customer_name || '-')}</div></div>
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${connection.parent_name || '-'}</div></div> <div class="detail-read-row"><span class="label">Parent</span><div class="value">${escapeHtml(connection.parent_name || '-')}</div></div>
<div class="detail-read-row"><span class="label">Abonnement</span><div class="value">${subscriptionText}</div></div> <div class="detail-read-row"><span class="label">${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}</span><div class="value">${escapeHtml(subscriptionText)}</div></div>
<div class="detail-read-row"><span class="label">Kontrakt</span><div class="value">${contractText}</div></div> <div class="detail-read-row"><span class="label">Kontrakt</span><div class="value">${contractText}</div></div>
<div class="detail-read-row"><span class="label">Fallback</span><div class="value">${connection.speed_mbps || 0} Mbps</div></div> <div class="detail-read-row"><span class="label">Nominel hastighed</span><div class="value">${connection.speed_mbps ? `${connection.speed_mbps} Mbps` : '-'}</div></div>
<div class="detail-read-row"><span class="label">Overvågning</span><div class="value">${monitoringText}</div></div> <div class="detail-read-row"><span class="label">Overvågning</span><div class="value">${monitoringText}</div></div>
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes || '<span class="detail-read-muted">-</span>'}</div></div> <div class="detail-read-row"><span class="label">SLA</span><div class="value">${connection.sla_subscription_number ? `${escapeHtml(connection.sla_product_name || 'SLA')} · ${formatDKK(connection.sla_price || 0)}` : '<span class="text-danger fw-semibold">Ingen SLA-aftale</span>'}</div></div>
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes ? escapeHtml(connection.notes) : '<span class="detail-read-muted">-</span>'}</div></div>
</div> </div>
`; `;
setStatusBadge(connection.status); setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || ''; document.getElementById('fieldName').value = connection.name || '';
document.getElementById('fieldProvider').value = connection.provider || ''; document.getElementById('fieldVendorId').value = connection.vendor_id || '';
document.getElementById('fieldCircuit').value = connection.circuit_number || ''; document.getElementById('fieldCircuit').value = connection.circuit_number || '';
document.getElementById('fieldAddress').value = connection.address || ''; document.getElementById('fieldAddress').value = connection.address || '';
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id); setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
@ -1484,6 +1716,7 @@
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || ''; document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated'; document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
document.getElementById('fieldValueType').value = connection.value_type || 'other'; document.getElementById('fieldValueType').value = connection.value_type || 'other';
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
document.getElementById('fieldValueLabel').value = connection.value_label || ''; document.getElementById('fieldValueLabel').value = connection.value_label || '';
setSubscriptionValue(connection.subscription_id); setSubscriptionValue(connection.subscription_id);
document.getElementById('fieldContractStart').value = connection.contract_start || ''; document.getElementById('fieldContractStart').value = connection.contract_start || '';
@ -1493,6 +1726,66 @@
toggleCoreEdit(false, true); toggleCoreEdit(false, true);
} }
function renderSla(connection) {
const banner = document.getElementById('slaBanner');
const content = document.getElementById('slaBannerContent');
const select = document.getElementById('slaSubscriptionSelect');
const customerId = Number(connection.customer_id || 0);
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null && connection.allocation_model === 'shared' && connection.value_type === 'delefiber')
);
if (isBmcSharedFiber) {
banner.className = 'd-none';
return;
}
const options = subscriptionOptions.filter((item) =>
Number(item.customer_id) === customerId && /\bsla\b/i.test(String(item.product_name || ''))
);
if (connection.sla_subscription_id && !options.some((item) => Number(item.id) === Number(connection.sla_subscription_id))) {
options.unshift({
id: connection.sla_subscription_id,
product_name: connection.sla_product_name,
subscription_number: connection.sla_subscription_number,
price: connection.sla_price,
status: connection.sla_status,
});
}
select.innerHTML = '<option value="">Ingen SLA-aftale</option>' + options.map((item) =>
`<option value="${item.id}" ${Number(item.id) === Number(connection.sla_subscription_id) ? 'selected' : ''}>${escapeHtml(item.product_name || 'SLA')} · ${escapeHtml(item.subscription_number || `#${item.id}`)} · ${formatDKK(item.price || 0)}</option>`
).join('');
select.disabled = !customerId;
const hasSla = Boolean(connection.sla_subscription_id);
const priceOk = Number(connection.sla_price || 0) > 0;
const statusOk = connection.sla_status === 'active';
banner.className = `alert mb-4 ${hasSla && priceOk && statusOk ? 'alert-success' : 'alert-warning'}`;
content.innerHTML = hasSla
? `<div class="fw-bold"><i class="bi bi-shield-check me-2"></i>${escapeHtml(connection.sla_product_name || 'SLA-aftale')}</div><div class="small mt-1">${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : '<strong>Prisen skal kontrolleres</strong>'}${statusOk ? '' : ' · <strong>SLA-aftalen er ikke aktiv</strong>'}</div>`
: `<div class="fw-bold"><i class="bi bi-shield-exclamation me-2"></i>Ingen SLA-aftale</div><div class="small mt-1">${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}</div>`;
}
async function saveSlaAllocation() {
const value = Number(document.getElementById('slaSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('slaFeedback');
const button = document.getElementById('saveSlaBtn');
button.disabled = true;
feedback.textContent = 'Gemmer SLA-allokering…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sla_subscription_id: value }),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'SLA-allokeringen kunne ikke gemmes.'));
feedback.textContent = 'SLA-allokeringen er gemt.';
await loadConnection();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message;
} finally {
button.disabled = false;
}
}
function toggleCoreEdit(force, silent = false) { function toggleCoreEdit(force, silent = false) {
coreEditMode = Boolean(force); coreEditMode = Boolean(force);
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode); document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
@ -1512,27 +1805,38 @@
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false); const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
if (!visibleRanges.length && !mismatchedRanges.length) { if (!visibleRanges.length && !mismatchedRanges.length) {
list.innerHTML = '<div class="text-muted">Ingen IP-ranges registreret endnu.</div>'; list.innerHTML = '<div class="ip-empty-state" style="grid-column:1/-1"><i class="bi bi-hdd-network"></i><strong>Ingen IP-ranges registreret</strong><div class="small text-muted mt-1">Tilføj et CIDR-range eller kontrollér kredsløbsreferencen.</div></div>';
rangeSelect.innerHTML = '<option value="">Ingen ranges</option>'; rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
return; return;
} }
const visibleMarkup = visibleRanges.map((range) => ` const visibleMarkup = visibleRanges.map((range) => `
<div class="detail-grid-card" id="rangeCard-${range.id}"> <div class="detail-grid-card ip-range-card" id="rangeCard-${range.id}">
<div class="ip-range-main">
<span class="label">${range.name || 'Range'}</span> <span class="label">${range.name || 'Range'}</span>
<div class="value">${range.cidr || '-'}</div> <div class="value ip-range-cidr"><i class="bi bi-globe2"></i>${range.cidr || '-'}</div>
<div class="small text-muted mt-2">Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}</div> <div class="small text-muted mt-1">${range.service_address || 'Ingen serviceadresse'}</div>
${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''} </div>
${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''} <div class="ip-range-meta">
${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''} <span class="label">Adresser</span>
${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</div>` : ''} <div class="value">${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige</div>
${(Number(range.monthly_cost || 0) || Number(range.sales_price || 0)) ? `<div class="small text-muted mt-1">Kost ${formatDKK(range.monthly_cost || 0)} · Salg ${formatDKK(range.sales_price || 0)}</div>` : ''} <div class="small text-muted">${range.usable_hosts || 0} brugbare i alt</div>
${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''} </div>
<div class="mt-3 d-flex gap-2"> <div class="ip-range-meta">
<span class="label">Reference</span>
<div class="value">${range.provider_reference || '-'}</div>
<div class="small text-muted">Kontrakt ${range.contract_number || '-'}</div>
</div>
<div class="ip-range-meta">
<span class="label">Økonomi</span>
<div class="value">${formatDKK(range.monthly_cost || 0)} kost</div>
<div class="small text-muted">${formatDKK(range.sales_price || 0)} salg${range.customer_name ? ` · ${range.customer_name}` : ''}</div>
</div>
<div class="ip-range-actions d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button> <button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button>
</div> </div>
<div class="small text-muted mt-2" id="rangeFeedback-${range.id}"></div> <div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div>
<div class="d-none" id="rangeEdit-${range.id}"> <div class="ip-range-edit d-none" id="rangeEdit-${range.id}">
<div class="range-edit-grid"> <div class="range-edit-grid">
<div> <div>
<label class="form-label small text-muted mb-1">Navn</label> <label class="form-label small text-muted mb-1">Navn</label>
@ -1581,15 +1885,12 @@
`).join(''); `).join('');
const mismatchMarkup = mismatchedRanges.length ? ` const mismatchMarkup = mismatchedRanges.length ? `
<div class="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;"> <div class="ip-range-warning">
<span class="label text-danger">Afvigelser</span> <i class="bi bi-exclamation-triangle-fill text-danger mt-1"></i>
<div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div> <div>
${mismatchedRanges.map((range) => ` <div class="fw-semibold text-danger">${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol</div>
<div class="small mb-2"> ${mismatchedRanges.map((range) => `<div class="small mt-1"><strong>${range.cidr}</strong> · ${range.alignment_warning || 'Matcher ikke forbindelsen'}</div>`).join('')}
<strong>${range.cidr}</strong> · ${range.customer_name || 'Ingen kunde'}<br>
<span class="text-danger">${range.alignment_warning || 'Matcher ikke forbindelsen'}</span>
</div> </div>
`).join('')}
</div> </div>
` : ''; ` : '';
@ -1661,7 +1962,7 @@
}); });
if (!filtered.length) { if (!filtered.length) {
body.innerHTML = '<tr><td colspan="5" class="text-muted py-4">Ingen IP-adresser matcher filtrene.</td></tr>'; body.innerHTML = '<tr><td colspan="5"><div class="ip-empty-state my-2"><i class="bi bi-search"></i><strong>Ingen IP-adresser matcher</strong><div class="small text-muted mt-1">Prøv at rydde søgning eller statusfilter.</div></div></td></tr>';
return; return;
} }
@ -1913,7 +2214,7 @@
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || ''; document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
const payload = { const payload = {
name: document.getElementById('fieldName').value.trim(), name: document.getElementById('fieldName').value.trim(),
provider: document.getElementById('fieldProvider').value.trim() || null, vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null,
circuit_number: document.getElementById('fieldCircuit').value.trim() || null, circuit_number: document.getElementById('fieldCircuit').value.trim() || null,
address: document.getElementById('fieldAddress').value.trim() || null, address: document.getElementById('fieldAddress').value.trim() || null,
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null, customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
@ -1929,6 +2230,7 @@
value_type: document.getElementById('fieldValueType').value || 'other', value_type: document.getElementById('fieldValueType').value || 'other',
value_label: document.getElementById('fieldValueLabel').value.trim() || null, value_label: document.getElementById('fieldValueLabel').value.trim() || null,
subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null, subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null,
is_manual_shared: document.getElementById('fieldManualShared').checked,
contract_start: document.getElementById('fieldContractStart').value || null, contract_start: document.getElementById('fieldContractStart').value || null,
contract_end: document.getElementById('fieldContractEnd').value || null, contract_end: document.getElementById('fieldContractEnd').value || null,
notes: document.getElementById('fieldNotes').value.trim() || null, notes: document.getElementById('fieldNotes').value.trim() || null,
@ -1976,6 +2278,19 @@
document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription'); document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
} }
function toggleManualDelefiber() {
const enabled = document.getElementById('fieldManualShared').checked;
if (enabled) {
document.getElementById('fieldAllocationModel').value = 'shared';
document.getElementById('fieldValueType').value = 'delefiber';
} else if (document.getElementById('fieldValueType').value === 'delefiber') {
document.getElementById('fieldAllocationModel').value = 'dedicated';
document.getElementById('fieldValueType').value = 'other';
document.getElementById('fieldValueLabel').value = 'Internetforbindelse';
}
toggleValueFields();
}
async function createRange() { async function createRange() {
const feedback = document.getElementById('rangeCreateFeedback'); const feedback = document.getElementById('rangeCreateFeedback');
const button = document.getElementById('createRangeButton'); const button = document.getElementById('createRangeButton');
@ -2039,7 +2354,7 @@
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null, assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
}; };
if (!payload.range_id || !payload.ip_address) { if (!payload.range_id || !payload.ip_address) {
alert('Range og IP-adresse er påkrævet'); showDetailMessage('Range og IP-adresse er påkrævet.', true);
return; return;
} }
@ -2049,7 +2364,7 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
alert('Kunne ikke oprette IP-adresse'); showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
return; return;
} }
document.getElementById('addressIpInput').value = ''; document.getElementById('addressIpInput').value = '';
@ -2090,7 +2405,7 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
alert('Kunne ikke opdatere IP-adressen'); showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
return; return;
} }
editingAddressId = null; editingAddressId = null;

View File

@ -173,13 +173,13 @@
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div> <div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview"> <button class="btn btn-outline-secondary" type="button" onclick="refreshActiveTab()">
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
</a>
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater <i class="bi bi-arrow-repeat me-1"></i>Opdater
</button> </button>
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock"> <button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
</button>
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse <i class="bi bi-plus-lg me-1"></i>Ny forbindelse
</button> </button>
</div> </div>
@ -188,10 +188,13 @@
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button> <button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button> <button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button> <button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
<button class="internet-tab" type="button" id="tabDedicated" onclick="setActiveTab('dedicated')">Dedikerede</button>
<button class="internet-tab" type="button" id="tabUnallocated" onclick="setActiveTab('unallocated')">Ikke allokeret</button>
<button class="internet-tab" type="button" id="tabInvoices" onclick="setActiveTab('invoices')">Behandlede internetfakturaer</button>
</div> </div>
</div> </div>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4" id="connectionsMetrics">
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="internet-kpi"> <div class="internet-kpi">
<div class="internet-kpi-label">Forbindelser</div> <div class="internet-kpi-label">Forbindelser</div>
@ -200,7 +203,7 @@
</div> </div>
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="internet-kpi"> <div class="internet-kpi">
<div class="internet-kpi-label">Aktive</div> <div class="internet-kpi-label">Aktive / afventer</div>
<div class="internet-kpi-value" id="metricActive">0</div> <div class="internet-kpi-value" id="metricActive">0</div>
</div> </div>
</div> </div>
@ -232,7 +235,7 @@
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" /> <input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
</div> </div>
<div class="col-lg-2"> <div class="col-lg-2">
<input type="text" class="form-control" id="connectionProviderInput" placeholder="Leverandør" /> <select class="form-select" id="connectionVendorInput"><option value="">Vælg internetleverandør</option></select>
</div> </div>
<div class="col-lg-2"> <div class="col-lg-2">
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" /> <input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
@ -296,13 +299,14 @@
</div> </div>
</div> </div>
<div class="internet-panel p-4 mb-4"> <div class="internet-panel p-4 mb-4" id="connectionsOverview">
<div class="internet-toolbar mb-3"> <div class="internet-toolbar mb-3">
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" /> <input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" /> <input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
<select class="form-select" id="statusFilter"> <select class="form-select" id="statusFilter">
<option value="">Alle statusser</option> <option value="">Alle statusser</option>
<option value="active">Aktive</option> <option value="active">Aktive</option>
<option value="pending">Afventer kontrol</option>
<option value="planned">Planlagte</option> <option value="planned">Planlagte</option>
<option value="inactive">Inaktive</option> <option value="inactive">Inaktive</option>
<option value="terminated">Opsagte</option> <option value="terminated">Opsagte</option>
@ -349,13 +353,14 @@
</div> </div>
</div> </div>
<div class="internet-panel p-4 mb-4" id="invoiceProcessingOverview"> <div class="internet-panel p-4 mb-4 d-none" id="invoiceProcessingOverview">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3"> <div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div> <div>
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3> <h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div> <div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<span class="internet-mini align-self-center" id="invoiceReconcileFeedback" role="status"></span>
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()"> <select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option> <option value="">Alle resultater</option>
<option value="success">Gennemført</option> <option value="success">Gennemført</option>
@ -364,6 +369,7 @@
<option value="skipped">Sprunget over</option> <option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option> <option value="not_logged">Ældre uden detaljer</option>
</select> </select>
<button class="btn btn-sm btn-outline-primary" id="invoiceReconcileBtn" type="button" onclick="reconcileInvoiceSyncRuns()"><i class="bi bi-arrow-clockwise me-1"></i>Genkontrollér</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button> <button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div> </div>
</div> </div>
@ -382,6 +388,36 @@
</div> </div>
</div> </div>
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-1">Importér IP Nordic-forbindelser</h5>
<div class="internet-mini">Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold" for="ipNordicFileInput">IP Nordic Excel-fil (.xlsx)</label>
<input class="form-control" id="ipNordicFileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
<div class="small mt-2" id="ipNordicImportFeedback" role="status"></div>
<div class="table-responsive mt-3 d-none" id="ipNordicPreviewWrap">
<table class="table table-sm align-middle">
<thead><tr><th>Handling</th><th>Rapporteret firma</th><th>Adresse</th><th>Start</th><th class="text-end">Kost</th><th class="text-end">Salg</th></tr></thead>
<tbody id="ipNordicPreviewBody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button>
<button class="btn btn-outline-primary" id="ipNordicPreviewBtn" type="button" onclick="previewIpNordicImport()">Kontrollér fil</button>
<button class="btn btn-primary d-none" id="ipNordicCommitBtn" type="button" onclick="commitIpNordicImport()">Importér nye forbindelser</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable"> <div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
@ -391,6 +427,10 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div> <div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showResolvedInvoiceLines" onchange="toggleResolvedInvoiceLines()">
<label class="form-check-label" for="showResolvedInvoiceLines">Vis allerede løste linjer</label>
</div>
<div id="invoiceReviewLines"></div> <div id="invoiceReviewLines"></div>
</div> </div>
</div> </div>
@ -402,6 +442,65 @@
let invoiceSyncItems = []; let invoiceSyncItems = [];
let activeTab = 'all'; let activeTab = 'all';
let subscriptionOptions = []; let subscriptionOptions = [];
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
}[char]));
}
async function runIpNordicImport(commit) {
const input = document.getElementById('ipNordicFileInput');
const feedback = document.getElementById('ipNordicImportFeedback');
const previewButton = document.getElementById('ipNordicPreviewBtn');
const commitButton = document.getElementById('ipNordicCommitBtn');
const file = input.files?.[0];
if (!file) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = 'Vælg først en Excel-fil.';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('commit', commit ? 'true' : 'false');
previewButton.disabled = true;
commitButton.disabled = true;
feedback.className = 'small mt-2 text-muted';
feedback.textContent = commit ? 'Importerer...' : 'Kontrollerer filen...';
try {
const response = await fetch('/api/v1/internet-connections/import/ip-nordic', { method: 'POST', body: formData });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Importen kunne ikke gennemføres.');
ipNordicPreview = payload;
document.getElementById('ipNordicPreviewWrap').classList.remove('d-none');
document.getElementById('ipNordicPreviewBody').innerHTML = (payload.items || []).map((item) => `
<tr>
<td>${item.action === 'skip' ? `<span class="badge text-bg-secondary">Findes #${item.existing_connection_id}</span>` : '<span class="badge text-bg-success">Ny</span>'}</td>
<td>${escapeHtml(item.reported_company || '-')}<div class="internet-mini">Nr. ${escapeHtml(item.company_number || '-')} · ${Number(item.line_count || 0)} linjer</div></td>
<td>${escapeHtml(item.address || '-')}</td>
<td>${escapeHtml(item.start_date || '-')}</td>
<td class="text-end">${formatDKK(item.monthly_cost)}</td>
<td class="text-end">${formatDKK(item.sales_price)}</td>
</tr>`).join('');
feedback.className = 'small mt-2 text-success';
feedback.textContent = commit
? `${payload.created_count} forbindelser oprettet, ${payload.skipped_count} eksisterende sprunget over.`
: `${payload.create_count} nye og ${payload.existing_count} eksisterende forbindelser fundet.`;
commitButton.classList.toggle('d-none', commit || payload.create_count === 0);
if (commit) await loadInternetPage();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message || 'Importen kunne ikke gennemføres.';
} finally {
previewButton.disabled = false;
commitButton.disabled = false;
}
}
function previewIpNordicImport() { return runIpNordicImport(false); }
function commitIpNordicImport() { return runIpNordicImport(true); }
function formatDKK(value) { function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 }); return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
@ -431,17 +530,19 @@
function allocationBadge(item) { function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret'); const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
return `<span class="internet-tag">${label}</span>`; return `<span class="internet-tag">${escapeHtml(label)}</span>`;
} }
function valueBadge(item) { function valueBadge(item) {
const label = item.value_type_label || 'Anden'; const label = item.value_type_label || 'Anden';
return `<span class="internet-tag">${label}</span>`; return `<span class="internet-tag">${escapeHtml(label)}</span>`;
} }
function currentTabDescription() { function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser'; if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet'; if (activeTab === 'bmcnet') return ' i BMCnet';
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
if (activeTab === 'unallocated') return ' uden allokeret kunde';
return ''; return '';
} }
@ -551,6 +652,27 @@
} }
} }
async function reconcileInvoiceSyncRuns() {
const button = document.getElementById('invoiceReconcileBtn');
const feedback = document.getElementById('invoiceReconcileFeedback');
button.disabled = true;
feedback.className = 'internet-mini align-self-center text-muted';
feedback.textContent = 'Genkontrollerer…';
try {
const response = await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile', { method: 'POST' });
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Genkontrollen fejlede.'));
const payload = await response.json();
feedback.className = 'internet-mini align-self-center text-success';
feedback.textContent = `${Number(payload.resolved_lines || 0)} linjer løst`;
await loadInvoiceSyncRuns();
} catch (error) {
feedback.className = 'internet-mini align-self-center text-danger';
feedback.textContent = error.message || 'Genkontrollen fejlede.';
} finally {
button.disabled = false;
}
}
function reviewConnectionOptions(selectedId = '') { function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect')) .filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
@ -558,14 +680,33 @@
.join(''); .join('');
} }
function openInvoiceReview(runId) { function toggleResolvedInvoiceLines() {
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId)); const show = document.getElementById('showResolvedInvoiceLines').checked;
document.querySelectorAll('#invoiceReviewLines .resolved-review-line').forEach((element) => {
element.classList.toggle('d-none', !show);
});
}
async function openInvoiceReview(runId) {
const summaryItem = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!summaryItem) return;
const linesWrap = document.getElementById('invoiceReviewLines');
linesWrap.innerHTML = '<div class="text-muted py-4">Henter kontrollinjer…</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}`);
if (!response.ok) {
linesWrap.innerHTML = `<div class="text-danger py-4">${escapeInvoiceText(await extractErrorMessage(response, 'Kunne ikke hente kontrollinjer.'))}</div>`;
return;
}
const detail = await response.json();
const item = { ...summaryItem, ...detail };
if (!item) return; if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : []; const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : []; const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision])); const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-'; document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`; document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
document.getElementById('showResolvedInvoiceLines').checked = false;
const groups = skippedItems.reduce((result, line) => { const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol'; const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line); (result[reason] ||= []).push(line);
@ -577,7 +718,7 @@
<div class="vstack gap-2"> <div class="vstack gap-2">
${lines.map(line => { ${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number)); const decision = decisionsByLine.get(Number(line.line_number));
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75' : ''}"> return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75 resolved-review-line d-none' : ''}">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3"> <div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div> <div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
@ -600,7 +741,6 @@
</div> </div>
</section> </section>
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>'; `).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
} }
async function submitInvoiceReview(runId, lineNumber, action) { async function submitInvoiceReview(runId, lineNumber, action) {
@ -645,22 +785,43 @@
if (valueType) params.set('value_type', valueType); if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true'); if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true'); if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
if (activeTab === 'dedicated') {
params.set('allocation_model', 'dedicated');
params.set('allocated_only', 'true');
}
if (activeTab === 'unallocated') params.set('unallocated_only', 'true');
try { try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`); const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []); const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : []; allConnections = Array.isArray(connections) ? connections : [];
allocationSuggestionsByConnection = new Map();
if (activeTab === 'unallocated') {
const allocationResponse = await fetch('/api/v1/internet-connections/allocation-overview');
const allocationPayload = await safeJson(allocationResponse, { items: [] });
allocationSuggestionsByConnection = new Map(
(allocationPayload.items || []).map((entry) => [Number(entry.connection_id), entry])
);
}
const total = allConnections.length; const total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').length; const active = allConnections.filter((item) => item.status === 'active').length;
const pending = allConnections.filter((item) => item.status === 'pending').length;
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length; const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length; const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0); const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total); document.getElementById('metricTotal').textContent = String(total);
document.getElementById('metricActive').textContent = String(active); document.getElementById('metricActive').textContent = `${active} / ${pending}`;
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder'; const focusMetric = activeTab === 'bmcnet'
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads); ? ['BMCnet', bmcnetConnections]
: activeTab === 'dedicated'
? ['Dedikerede', total]
: activeTab === 'unallocated'
? ['Uden kunde', total]
: ['Delte hoveder', sharedHeads];
document.getElementById('metricSharedLabel').textContent = focusMetric[0];
document.getElementById('metricShared').textContent = String(focusMetric[1]);
document.getElementById('metricMargin').textContent = formatDKK(margin); document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections); renderConnections(allConnections);
@ -679,23 +840,32 @@
return; return;
} }
body.innerHTML = connections.map((item) => ` body.innerHTML = connections.map((item) => {
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
return `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'"> <tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td> <td>
<div class="fw-semibold">${item.name || '-'}</div> <div class="fw-semibold">${escapeHtml(item.name || '-')}</div>
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div> <div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
<div class="internet-mini">${item.address || '-'}</div> <div class="internet-mini">${escapeHtml(item.address || '-')}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</div>` : ''} ${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${escapeHtml(item.parent_name)}</div>` : ''}
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''} ${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
</td> </td>
<td> <td>
<div>${item.customer_name || '-'}</div> <div>${escapeHtml(item.customer_name || '-')}</div>
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div> <div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
${uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${item.sla_subscription_id
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
</td> </td>
<td> <td>
<div>${item.provider || '-'}</div> <div>${escapeHtml(item.provider || '-')}</div>
<div class="internet-mini">${item.circuit_number || 'Intet kredsløb'}</div> <div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${item.subscription_number} · ${item.subscription_product_name || '-'}</div>` : (item.value_label ? `<div class="internet-mini">${item.value_label}</div>` : '')} ${item.subscription_number ? `<div class="internet-mini">Abonnement ${escapeHtml(item.subscription_number)} · ${escapeHtml(item.subscription_product_name || '-')}</div>` : (item.value_label ? `<div class="internet-mini">${escapeHtml(item.value_label)}</div>` : '')}
</td> </td>
<td> <td>
<div>${formatSpeed(item)}</div> <div>${formatSpeed(item)}</div>
@ -713,7 +883,19 @@
</a> </a>
</td> </td>
</tr> </tr>
`).join(''); `}).join('');
}
async function assignSuggestedCustomer(connectionId, customerId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId }),
});
if (!response.ok) {
document.getElementById('pageSummaryText').textContent = await extractErrorMessage(response, 'Kunden kunne ikke tildeles.');
return;
}
await loadInternetPage();
} }
async function submitConnectionForm() { async function submitConnectionForm() {
@ -721,7 +903,7 @@
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary'); const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = { const payload = {
name: document.getElementById('connectionNameInput').value.trim(), name: document.getElementById('connectionNameInput').value.trim(),
provider: document.getElementById('connectionProviderInput').value.trim() || null, vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null, customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null, circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null, address: document.getElementById('connectionAddressInput').value.trim() || null,
@ -737,6 +919,8 @@
value_type: document.getElementById('connectionValueTypeInput').value || 'other', value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null, value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null, subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
&& document.getElementById('connectionValueTypeInput').value === 'delefiber',
}; };
if (!payload.name) { if (!payload.name) {
@ -769,7 +953,7 @@
feedback.textContent = 'Forbindelse oprettet.'; feedback.textContent = 'Forbindelse oprettet.';
[ [
'connectionNameInput', 'connectionNameInput',
'connectionProviderInput', 'connectionVendorInput',
'connectionCustomerIdInput', 'connectionCustomerIdInput',
'connectionCircuitInput', 'connectionCircuitInput',
'connectionAddressInput', 'connectionAddressInput',
@ -811,7 +995,23 @@
document.getElementById('tabAll').classList.toggle('active', tab === 'all'); document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared'); document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet'); document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
loadInternetPage(); document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
const invoiceMode = tab === 'invoices';
document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
document.getElementById('createConnectionBlock').classList.add('collapse');
document.getElementById('createConnectionBlock').classList.remove('show');
document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
if (invoiceMode) loadInvoiceSyncRuns();
else loadInternetPage();
}
function refreshActiveTab() {
if (activeTab === 'invoices') return loadInvoiceSyncRuns();
return loadInternetPage();
} }
function toggleCreateValueFields() { function toggleCreateValueFields() {
@ -845,6 +1045,18 @@
.join(''); .join('');
} }
async function loadInternetVendors() {
const select = document.getElementById('connectionVendorInput');
try {
const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
}
}
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => { document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage(); if (event.key === 'Enter') loadInternetPage();
@ -853,7 +1065,7 @@
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || ''; document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
}); });
toggleCreateValueFields(); toggleCreateValueFields();
await loadSubscriptionOptions(); await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]); await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
}); });
</script> </script>

View File

@ -419,7 +419,15 @@ class SagBuzzwordSelectionRequest(BaseModel):
class SagListPreferencesUpdate(BaseModel): class SagListPreferencesUpdate(BaseModel):
type_filters: List[str] = Field(default_factory=list) type_filters: Optional[List[str]] = None
column_order: Optional[List[str]] = None
hidden_columns: Optional[List[str]] = None
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]: def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@ -1013,6 +1021,15 @@ async def create_sag(request: Request, data: dict):
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket" 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 pipeline = data.get("pipeline") if case_type == "pipeline" else None
order_items = data.get("order_items") if case_type == "ordre" else [] order_items = data.get("order_items") if case_type == "ordre" else []
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")
contact_ids = []
for raw_contact_id in raw_contact_ids:
contact_id = _coerce_optional_int(raw_contact_id, "contact_id")
if contact_id and contact_id not in contact_ids:
contact_ids.append(contact_id)
telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id")
if pipeline is not None and not isinstance(pipeline, dict): if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt") raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list): if not isinstance(order_items, list):
@ -1098,6 +1115,37 @@ async def create_sag(request: Request, data: dict):
if not result: if not result:
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
if contact_ids:
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
if missing_contact_ids:
raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
for index, contact_id in enumerate(contact_ids):
cursor.execute(
"""INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
VALUES (%s, %s, 'Kontakt', %s)""",
(result["id"], contact_id, index == 0),
)
cursor.execute(
"""INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, FALSE, 'Kontakt')
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, data.get("customer_id")),
)
if telefoni_opkald_id:
cursor.execute(
"""UPDATE telefoni_opkald
SET sag_id = %s,
kontakt_id = COALESCE(%s, kontakt_id)
WHERE id = %s
RETURNING id""",
(result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
)
if not cursor.fetchone():
raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items: for item in normalized_items:
if has_purchase_columns: if has_purchase_columns:
@ -1259,16 +1307,14 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
async def get_my_sag_list_preferences(request: Request): async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: try:
has_columns = table_has_column("user_sag_list_preferences", "column_order")
select_fields = "type_filters, column_order, hidden_columns" if has_columns else "type_filters"
rows = execute_query( rows = execute_query(
""" f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
SELECT type_filters
FROM user_sag_list_preferences
WHERE user_id = %s
""",
(user_id,), (user_id,),
) or [] ) or []
if not rows: if not rows:
return {"type_filters": []} return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
raw = rows[0].get("type_filters") raw = rows[0].get("type_filters")
parsed = [] parsed = []
@ -1291,20 +1337,43 @@ async def get_my_sag_list_preferences(request: Request):
seen.add(item) seen.add(item)
normalized.append(item) normalized.append(item)
return {"type_filters": normalized} raw_order = rows[0].get("column_order") if has_columns else None
raw_hidden = rows[0].get("hidden_columns") if has_columns else None
if isinstance(raw_order, str):
try: raw_order = json.loads(raw_order)
except Exception: raw_order = []
if isinstance(raw_hidden, str):
try: raw_hidden = json.loads(raw_hidden)
except Exception: raw_hidden = []
order = [str(key) for key in (raw_order or []) if str(key) in SAG_LIST_COLUMN_KEYS]
order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in order)
hidden = [str(key) for key in (raw_hidden or []) if str(key) in SAG_LIST_COLUMN_KEYS]
return {"type_filters": normalized, "column_order": order, "hidden_columns": hidden}
except Exception as e: except Exception as e:
if "user_sag_list_preferences" in str(e): if "user_sag_list_preferences" in str(e):
return {"type_filters": []} return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e) logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to load list preferences") raise HTTPException(status_code=500, detail="Failed to load list preferences")
@router.get("/sag/me/quick-filter-context")
async def get_my_sag_quick_filter_context(request: Request):
"""Return the authenticated employee and their groups for list quick filters."""
user_id = _get_user_id_from_request(request)
rows = execute_query(
"SELECT group_id FROM user_groups WHERE user_id = %s ORDER BY group_id",
(user_id,),
) or []
return {"user_id": user_id, "group_ids": [row["group_id"] for row in rows]}
@router.patch("/sag/me/list-preferences") @router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate): async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: try:
normalized = [] normalized = []
seen = set() seen = set()
for value in payload.type_filters or []: existing = await get_my_sag_list_preferences(request)
for value in (payload.type_filters if payload.type_filters is not None else existing["type_filters"]):
item = str(value or "").strip().lower() item = str(value or "").strip().lower()
if not item or item in seen: if not item or item in seen:
continue continue
@ -1313,18 +1382,31 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
seen.add(item) seen.add(item)
normalized.append(item) normalized.append(item)
requested_order = payload.column_order if payload.column_order is not None else existing["column_order"]
column_order = [str(key) for key in requested_order if str(key) in SAG_LIST_COLUMN_KEYS]
column_order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in column_order)
requested_hidden = payload.hidden_columns if payload.hidden_columns is not None else existing["hidden_columns"]
hidden_columns = list(dict.fromkeys(str(key) for key in requested_hidden if str(key) in SAG_LIST_COLUMN_KEYS))
if not table_has_column("user_sag_list_preferences", "column_order"):
raise HTTPException(status_code=503, detail="Kolonnepræferencer kræver den nyeste databasemigration")
execute_query( execute_query(
""" """
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at) INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
VALUES (%s, %s::jsonb, NOW()) VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (user_id) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
type_filters = EXCLUDED.type_filters, type_filters = EXCLUDED.type_filters,
column_order = EXCLUDED.column_order,
hidden_columns = EXCLUDED.hidden_columns,
updated_at = NOW() updated_at = NOW()
""", """,
(user_id, json.dumps(normalized)), (user_id, json.dumps(normalized), json.dumps(column_order), json.dumps(hidden_columns)),
) )
return {"type_filters": normalized} return {"type_filters": normalized, "column_order": column_order, "hidden_columns": hidden_columns}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e) logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to save list preferences") raise HTTPException(status_code=500, detail="Failed to save list preferences")
@ -1541,6 +1623,7 @@ async def delete_todo_step(step_id: int):
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case.""" """Update a case."""
try: try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
# Check if case exists # Check if case exists
check = execute_query( check = execute_query(
""" """
@ -1562,6 +1645,23 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
if "status" in updates: if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status")) updates["status"] = _normalize_case_status(updates.get("status"))
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
new_status = str(updates.get("status") or "").strip().lower()
case_type = str(previous_row.get("template_key") or "").strip().lower()
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
has_registered_time = execute_query_single(
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
(sag_id,),
) or {}
if not bool(has_registered_time.get("has_time")) and not confirm_close_without_time:
raise HTTPException(
status_code=409,
detail={
"code": "close_without_time_confirmation_required",
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
},
)
if "deadline" in updates: if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates: if "start_date" in updates:

View File

@ -1,118 +1,231 @@
import json
import logging import logging
from fastapi import APIRouter, HTTPException, Request, Depends
from typing import Optional from typing import Optional
from app.core.database import execute_query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.core.database import execute_query, execute_query_single
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
from app.core.auth_dependencies import require_any_permission
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
case_edit_access = require_any_permission("cases.edit", "tickets.edit") case_edit_access = require_any_permission("cases.edit", "tickets.edit")
VISIBILITIES = {"internal", "general", "customer"}
APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"}
RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"}
TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"}
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int):
"""Get the solution associated with a case."""
try:
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
result = execute_query(query, (sag_id,))
if not result:
return None
return result[0]
except Exception as e:
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution")
@router.post( def _user_id(current_user: dict) -> Optional[int]:
"/sag/{sag_id}/solution", value = current_user.get("id") or current_user.get("user_id")
response_model=Solution, return int(value) if value is not None else None
dependencies=[Depends(case_edit_access)],
)
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
"""Create a solution for a case."""
try:
# Check if case exists
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
if not case_check:
raise HTTPException(status_code=404, detail="Case not found")
# Check if solution already exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if check:
raise HTTPException(status_code=400, detail="Solution already exists for this case")
query = """ def _clean_list(values) -> list[str]:
INSERT INTO sag_solutions cleaned, seen = [], set()
(sag_id, title, description, solution_type, result, created_by_user_id) for value in values or []:
VALUES (%s, %s, %s, %s, %s, %s) item = str(value or "").strip()
RETURNING * key = item.casefold()
""" if item and key not in seen:
params = ( seen.add(key)
sag_id, cleaned.append(item[:100])
solution.title, return cleaned[:30]
solution.description,
solution.solution_type,
solution.result, def _normalize_payload(data: dict) -> dict:
getattr(request.state, "user_id", None) if "title" in data:
data["title"] = str(data.get("title") or "").strip()
if not data["title"]:
raise HTTPException(status_code=422, detail="Løsningen skal have en titel")
if "visibility" in data:
data["visibility"] = str(data.get("visibility") or "internal").lower()
if data["visibility"] not in VISIBILITIES:
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
if "approval_status" in data:
data["approval_status"] = str(data.get("approval_status") or "draft").lower()
if data["approval_status"] not in APPROVAL_STATUSES:
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
if data.get("result") is not None:
raw = str(data["result"]).strip()
data["result"] = RESULT_ALIASES.get(raw.casefold(), raw)
if data.get("solution_type") is not None:
raw = str(data["solution_type"]).strip()
data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw)
for field in ("tags", "products"):
if field in data:
data[field] = _clean_list(data[field])
return data
def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None:
version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1}
snapshot = dict(solution)
for key, value in list(snapshot.items()):
if hasattr(value, "isoformat"):
snapshot[key] = value.isoformat()
execute_query(
"INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)",
(solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note),
) )
result = execute_query(query, params)
if result:
logger.info("✅ Solution created for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to create solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to create solution")
@router.patch( @router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
"/sag/{sag_id}/solution", async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
response_model=Solution, result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s", (sag_id,))
dependencies=[Depends(case_edit_access)], return result[0] if result else None
)
async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution."""
try:
# Check if solution exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Solution not found")
# Build dynamic update query
set_clauses = []
params = []
# Helper to check and add params @router.get("/sag/{sag_id}/solution/versions")
if updates.title is not None: async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)):
set_clauses.append("title = %s") solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
params.append(updates.title) if not solution:
if updates.description is not None: return {"items": [], "total": 0}
set_clauses.append("description = %s") items = execute_query(
params.append(updates.description) """SELECT v.id, v.version_number, v.change_note, v.created_at,
if updates.solution_type is not None: COALESCE(u.full_name, u.username) AS changed_by
set_clauses.append("solution_type = %s") FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id
params.append(updates.solution_type) WHERE v.solution_id=%s ORDER BY v.version_number DESC""",
if updates.result is not None: (solution["id"],),
set_clauses.append("result = %s") ) or []
params.append(updates.result) return {"items": items, "total": len(items)}
if not set_clauses:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses.append("updated_at = NOW()") @router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)):
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
if execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)):
raise HTTPException(status_code=409, detail="Der findes allerede en løsning på sagen")
data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
user_id = _user_id(current_user)
result = execute_query(
"""INSERT INTO sag_solutions (
sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround,
visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""",
(sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id),
)
created = result[0]
_version_solution(created, user_id, "Løsning oprettet")
return created
params.append(sag_id)
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
result = execute_query(query, tuple(params)) @router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
if result: async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
logger.info("✅ Solution updated for case: %s", sag_id) if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)):
return result[0] raise HTTPException(status_code=404, detail="Løsningen findes ikke")
raise HTTPException(status_code=500, detail="Failed to update solution") data = _normalize_payload(updates.model_dump(exclude_unset=True))
except HTTPException: change_note = data.pop("change_note", None)
raise allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
except Exception as e: fields, params = [], []
logger.error("❌ Error updating solution: %s", e) for key, value in data.items():
raise HTTPException(status_code=500, detail="Failed to update solution") if key not in allowed:
continue
fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else ""))
params.append(json.dumps(value) if key in {"tags", "products"} else value)
if not fields:
raise HTTPException(status_code=400, detail="Ingen ændringer at gemme")
user_id = _user_id(current_user)
fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"])
params.extend([user_id, sag_id])
updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0]
_version_solution(updated, user_id, change_note or "Løsning redigeret")
return updated
@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)])
async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)):
action = str((await request.json()).get("action") or "").lower()
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
user_id = _user_id(current_user)
if action == "submit":
status = "pending"
elif action == "approve":
if not str(solution.get("description") or "").strip():
raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse")
status = "approved"
elif action in {"reject", "outdate"}:
status = "rejected" if action == "reject" else "outdated"
else:
raise HTTPException(status_code=422, detail="Ukendt handling")
if status == "approved":
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id))
else:
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id))
_version_solution(updated, user_id, f"Status ændret til {status}")
return updated
@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)])
async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
if solution.get("approval_status") != "approved":
raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse")
customer_id = None
if solution.get("visibility") == "customer":
customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,))
if not customer:
raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen")
customer_id = customer["customer_id"]
description = str(solution.get("description") or "").strip()
summary = description[:300] + ("" if len(description) > 300 else "")
article = execute_query_single(
"""INSERT INTO knowledge_articles (
solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround,
visibility,status,tags,products,published_by_user_id,reviewed_at
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW())
ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title,
summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause,
investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published',
version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
reviewed_at=NOW(),updated_at=NOW() RETURNING *""",
(solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)),
)
return article
@router.get("/knowledge/articles")
async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)):
term = q.strip()
scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')"
params: list = [customer_id] if customer_id else []
search = ""
if term:
search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)"
params.extend([term, f"%{term}%"])
count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0}
item_params = []
rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0"
if term:
item_params.append(term)
item_params.extend(params)
item_params.extend([limit, offset])
items = execute_query(
f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
WHERE ka.status='published' AND {scope}{search}
ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""",
tuple(item_params),
) or []
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset}
@router.get("/knowledge/articles/{article_id}")
async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)):
article = execute_query_single(
"""SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
LEFT JOIN users u ON u.user_id=ka.published_by_user_id
WHERE ka.id=%s AND ka.status='published'""",
(article_id,),
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return article

View File

@ -13,6 +13,33 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.get("/knowledge", response_class=HTMLResponse)
async def knowledge_index(request: Request):
"""Read-only first release of the case knowledge base."""
return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int):
article = execute_query(
"""
SELECT ka.*, c.name AS customer_name,
COALESCE(u.full_name, u.username) AS published_by
FROM knowledge_articles ka
LEFT JOIN customers c ON c.id = ka.customer_id
LEFT JOIN users u ON u.user_id = ka.published_by_user_id
WHERE ka.id = %s AND ka.status = 'published'
""",
(article_id,),
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return templates.TemplateResponse(
"modules/sag/templates/knowledge_detail.html",
{"request": request, "article": article[0]},
)
def _render_api_print_bridge(api_path: str, page_title: str) -> str: def _render_api_print_bridge(api_path: str, page_title: str) -> str:
safe_api_path = json.dumps(api_path) safe_api_path = json.dumps(api_path)
safe_title = json.dumps(page_title) safe_title = json.dumps(page_title)
@ -203,6 +230,7 @@ async def sager_liste(
assigned_group_id: str = Query(None), assigned_group_id: str = Query(None),
unassigned: bool = Query(False), unassigned: bool = Query(False),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
quick: str = Query(None),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: try:
@ -215,6 +243,7 @@ async def sager_liste(
query = """ query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
sk_first.contact_id AS kontakt_id,
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, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name, g.name AS assigned_group_name,
@ -278,7 +307,8 @@ async def sager_liste(
query += ")" query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())" query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_status = str(status or "").strip().lower() normalized_quick = str(quick or "").strip().lower()
normalized_status = "all" if normalized_quick == "closed" else str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower() normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all": if normalized_status == "all":
pass pass
@ -316,6 +346,7 @@ async def sager_liste(
fallback_query = """ fallback_query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
NULL::integer AS kontakt_id,
'' as kontakt_navn, '' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name, NULL::text AS assigned_group_name,
@ -439,6 +470,7 @@ async def sager_liste(
"relations_map": relations_map, "relations_map": relations_map,
"child_ids": list(child_ids), "child_ids": list(child_ids),
"statuses": status_options, "statuses": status_options,
"status_options": status_options,
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_priority": normalized_priority, "current_priority": normalized_priority,
@ -451,16 +483,19 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned, "current_unassigned": requested_unassigned,
"current_quick_filter": normalized_quick or "all",
"closed_statuses": closed_statuses, "closed_statuses": closed_statuses,
}) })
except Exception: except Exception:
logger.exception("❌ Error displaying case list") logger.exception("❌ Error displaying case list")
fallback_status_options = _fetch_case_status_options()
return templates.TemplateResponse("modules/sag/templates/index.html", { return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request, "request": request,
"sager": [], "sager": [],
"relations_map": {}, "relations_map": {},
"child_ids": [], "child_ids": [],
"statuses": _fetch_case_status_options(), "statuses": fallback_status_options,
"status_options": fallback_status_options,
"all_tags": [], "all_tags": [],
"current_status": status, "current_status": status,
"current_priority": str(priority or "").strip().lower(), "current_priority": str(priority or "").strip().lower(),
@ -473,6 +508,7 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned, "current_unassigned": requested_unassigned,
"current_quick_filter": str(quick or "").strip().lower() or "all",
"closed_statuses": _fetch_closed_case_statuses(), "closed_statuses": _fetch_closed_case_statuses(),
}) })

View File

@ -475,6 +475,13 @@
}, duration); }, duration);
} }
function showCreateError(message) {
const errorDiv = document.getElementById('error');
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = String(message || 'Ukendt fejl');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// --- Character Counter --- // --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse'); const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) { if (beskrInput) {
@ -494,7 +501,7 @@
if (!source) { if (!source) {
descriptionInput?.focus(); descriptionInput?.focus();
alert('Skriv en beskrivelse først.'); showCreateError('Skriv en beskrivelse først.');
return; return;
} }
@ -529,7 +536,7 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show(); bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) { } catch (error) {
console.error('Case create rewrite failed:', error); console.error('Case create rewrite failed:', error);
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`); showCreateError(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally { } finally {
if (button) { if (button) {
button.disabled = false; button.disabled = false;
@ -999,7 +1006,7 @@
if (!anydeskId) return; if (!anydeskId) return;
try { try {
await navigator.clipboard.writeText(anydeskId); await navigator.clipboard.writeText(anydeskId);
alert('AnyDesk ID kopieret'); showSuccessAlert('AnyDesk ID kopieret');
} catch (err) { } catch (err) {
console.error('Copy failed', err); console.error('Copy failed', err);
} }
@ -1012,13 +1019,13 @@
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null; const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) { if (!name) {
alert('Navn er påkrævet'); showCreateError('Navn er påkrævet');
return; return;
} }
const customerId = selectedCustomer?.id || getSingleContactCompanyId(); const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) { if (!customerId) {
alert('Vælg et firma før du opretter hardware'); showCreateError('Vælg et firma før du opretter hardware');
return; return;
} }
@ -1044,7 +1051,7 @@
document.getElementById('hardwareAnyDeskIdInput').value = ''; document.getElementById('hardwareAnyDeskIdInput').value = '';
await loadHardwareForContacts(); await loadHardwareForContacts();
} catch (err) { } catch (err) {
alert('Fejl: ' + err.message); showCreateError('Fejl: ' + err.message);
} }
} }
@ -1335,7 +1342,9 @@
customer_id: selectedCustomer ? selectedCustomer.id : null, customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : 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, assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null,
contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite),
telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null
}; };
if (data.type === 'pipeline') { if (data.type === 'pipeline') {
@ -1361,63 +1370,9 @@
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
// Add contacts if any
const contactPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contact_id: parseInt(cid),
role: 'Kontakt',
is_primary: false
})
})
);
await Promise.all(contactPromises);
// Link telephony call -> case (best-effort)
if (telefoniPrefill.callId) {
try {
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: result.id,
kontakt_id: telefoniPrefill.contactId || null
})
});
} catch (e) {
console.warn('Telefoni link failed', e);
}
}
// Ensure contact-company link exists
if (selectedCustomer) {
const linkPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
})
);
const linkResponses = await Promise.all(linkPromises);
const linkFailed = linkResponses.find(res => !res.ok);
if (linkFailed) {
const err = await linkFailed.json();
throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
}
}
successDiv.classList.remove('d-none'); successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer..."; document.getElementById('success-text').textContent = "Sag oprettet";
setTimeout(() => {
window.location.href = `/sag/${result.id}/v3`; window.location.href = `/sag/${result.id}/v3`;
}, 1000);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag"; let errMsg = "Kunne ikke oprette sag";

File diff suppressed because it is too large Load Diff

View File

@ -299,8 +299,6 @@
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
}; };
console.log('Updating case with data:', data);
try { try {
const response = await fetch(`/api/v1/sag/${caseId}`, { const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH', method: 'PATCH',
@ -310,17 +308,11 @@
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
console.log('Response status:', response.status);
if (response.ok) { if (response.ok) {
const result = await response.json(); await response.json();
console.log('Updated case:', result); document.getElementById('success').textContent = `✅ Sag opdateret`;
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
document.getElementById('success').style.display = 'block'; document.getElementById('success').style.display = 'block';
setTimeout(() => {
window.location.href = `/sag/${caseId}/v3`; window.location.href = `/sag/${caseId}/v3`;
}, 1000);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
console.error('Error response:', errorText); console.error('Error response:', errorText);

View File

@ -5,17 +5,54 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.search-bar { .search-bar {
position: relative;
margin-bottom: 0; margin-bottom: 0;
flex: 1 1 380px; flex: 1 1 460px;
min-width: 240px; min-width: 240px;
} }
.search-bar input { .search-bar input {
border-radius: 8px; min-height: 46px;
border: 1px solid rgba(0,0,0,0.1); border-radius: 12px;
padding: 0.45rem 0.85rem; border: 1px solid rgba(15,76,117,.15);
padding: 0.6rem 4.5rem 0.6rem 2.7rem;
background: var(--bg-card);
box-shadow: 0 4px 16px rgba(15, 76, 117, .06);
transition: border-color .16s ease, box-shadow .16s ease;
} }
.search-bar input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15,76,117,.12), 0 5px 18px rgba(15,76,117,.08);
}
.search-bar .search-icon {
position: absolute; left: .95rem; top: 50%; transform: translateY(-50%);
color: var(--accent); pointer-events: none;
}
.search-clear-btn {
position: absolute; right: .45rem; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border: 0; border-radius: 9px;
background: transparent; color: var(--text-secondary);
}
.search-clear-btn:hover { background: rgba(15,76,117,.09); color: var(--accent); }
.search-shortcut {
position:absolute; right:2.85rem; top:50%; transform:translateY(-50%);
padding:.1rem .35rem; border:1px solid rgba(0,0,0,.12); border-radius:5px;
color:var(--text-secondary); font-size:.68rem; background:rgba(255,255,255,.7);
}
.sag-toolbar-card { padding: .85rem; margin-bottom: .9rem; background: var(--bg-card); border:1px solid rgba(15,76,117,.09); border-radius:16px; box-shadow:0 5px 20px rgba(15,76,117,.055); }
.sag-quick-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.48rem; padding-top:.7rem; margin-top:.7rem; border-top:1px solid rgba(15,76,117,.08); }
.sag-quick-filter { display:inline-flex; align-items:center; gap:.4rem; padding:.43rem .72rem; border:1px solid rgba(15,76,117,.14); border-radius:999px; background:rgba(15,76,117,.035); color:var(--text-primary); font-size:.8rem; font-weight:650; transition:all .16s ease; }
.sag-quick-filter:hover { border-color:rgba(15,76,117,.32); background:rgba(15,76,117,.09); color:var(--accent); transform:translateY(-1px); }
.sag-quick-filter.active { color:#fff; background:var(--accent); border-color:var(--accent); box-shadow:0 4px 12px rgba(15,76,117,.2); }
.sag-quick-filter[data-quick-filter="overdue"].active { background:#c92a2a; border-color:#c92a2a; }
.sag-advanced-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:1rem; padding:.6rem .7rem; border-radius:12px; background:rgba(15,76,117,.035); border:1px solid rgba(15,76,117,.07); }
.top-controls-row { .top-controls-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -90,6 +127,21 @@
background: var(--accent-light); background: var(--accent-light);
} }
.sag-table tbody tr.sag-deadline-overdue {
background: rgba(201, 42, 42, 0.045);
box-shadow: inset 5px 0 0 #c92a2a, inset 0 0 18px rgba(201, 42, 42, 0.11);
}
.sag-table tbody tr.sag-deadline-overdue:hover {
background: rgba(201, 42, 42, 0.09);
box-shadow: inset 5px 0 0 #b42318, inset 0 0 22px rgba(201, 42, 42, 0.16);
}
.sag-table tbody tr.sag-deadline-overdue td:last-child {
color: #b42318 !important;
font-weight: 700;
}
.sag-table tbody td { .sag-table tbody td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
vertical-align: top; vertical-align: top;
@ -117,10 +169,75 @@
max-width: 360px; max-width: 360px;
} }
.sag-column-list { min-width: 260px; max-height: 420px; overflow-y: auto; }
.sag-column-item { display:flex; align-items:center; gap:.55rem; padding:.45rem .6rem; border-radius:8px; cursor:grab; }
.sag-column-item:hover, .sag-column-item.dragging { background:rgba(15,76,117,.1); }
.sag-column-item .drag-handle { color:var(--text-secondary); cursor:grab; }
.sag-id { .sag-id {
font-weight: 700; display: inline-flex;
align-items: center;
gap: 0.28rem;
padding: 0.22rem 0.48rem;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 7px;
background: rgba(15, 76, 117, 0.07);
color: var(--accent); color: var(--accent);
font-size: 0.95rem; font-weight: 700;
font-size: 0.82rem;
line-height: 1.2;
text-decoration: none;
transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
}
.sag-id:hover {
color: var(--accent);
background: rgba(15, 76, 117, 0.14);
border-color: rgba(15, 76, 117, 0.3);
transform: translateY(-1px);
text-decoration: none;
}
.sag-entity-link {
display: inline-flex;
align-items: center;
gap: 0.38rem;
max-width: 100%;
padding: 0.25rem 0.5rem;
border-radius: 7px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.25;
text-decoration: none;
transition: color .16s ease, background-color .16s ease, transform .16s ease;
}
.sag-entity-link i {
flex: 0 0 auto;
color: var(--text-secondary);
font-size: 0.78rem;
}
.sag-entity-link span {
overflow: hidden;
text-overflow: ellipsis;
}
.sag-entity-link:hover {
color: var(--accent);
background: rgba(15, 76, 117, 0.09);
text-decoration: none;
transform: translateX(2px);
}
.sag-entity-link:hover i {
color: var(--accent);
}
.sag-id:focus-visible,
.sag-entity-link:focus-visible {
outline: 3px solid rgba(15, 76, 117, 0.22);
outline-offset: 2px;
} }
.sag-unread-badge { .sag-unread-badge {
@ -288,6 +405,64 @@
color: #065f46; color: #065f46;
} }
.sag-inline-select {
min-height: 34px;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 9px;
background-color: rgba(15, 76, 117, 0.045);
color: var(--text-primary);
font-size: 0.8rem;
font-weight: 600;
padding: 0.34rem 2rem 0.34rem 0.62rem;
box-shadow: none;
cursor: pointer;
transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.sag-inline-select:hover:not(:disabled) {
border-color: rgba(15, 76, 117, 0.38);
background-color: rgba(15, 76, 117, 0.09);
}
.sag-inline-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.14);
}
.sag-inline-select:disabled {
opacity: .62;
cursor: wait;
}
.sag-owner-select {
background-color: rgba(99, 102, 241, 0.055);
border-color: rgba(99, 102, 241, 0.17);
}
.sag-status-select.status-tone-open {
color: #8a5a00;
background-color: #fff8df;
border-color: #efd991;
}
.sag-status-select.status-tone-progress {
color: #174a89;
background-color: #eaf3ff;
border-color: #b8d5fa;
}
.sag-status-select.status-tone-waiting {
color: #8a4600;
background-color: #fff1df;
border-color: #f0c58e;
}
.sag-status-select.status-tone-done {
color: #087044;
background-color: #e5f8ef;
border-color: #acdcbc;
}
.filter-pills { .filter-pills {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -649,7 +824,8 @@
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;"> <div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div> <div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<div class="top-controls-row"> <div class="sag-toolbar-card">
<div class="top-controls-row mb-0">
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;"> <h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
<i class="bi bi-list-check"></i> <i class="bi bi-list-check"></i>
</h1> </h1>
@ -666,11 +842,14 @@
</div> </div>
<div class="search-bar"> <div class="search-bar">
<i class="bi bi-search search-icon"></i>
<input type="text" <input type="text"
class="form-control" class="form-control"
id="searchInput" id="searchInput"
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..." placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..."
autocomplete="off"> autocomplete="off">
<span class="search-shortcut">/</span>
<button class="search-clear-btn d-none" id="clearSearchBtn" type="button" title="Ryd søgning" aria-label="Ryd søgning"><i class="bi bi-x-lg"></i></button>
</div> </div>
<div class="top-controls-actions"> <div class="top-controls-actions">
@ -682,13 +861,17 @@
</button> </button>
</div> </div>
</div> </div>
<div class="sag-quick-filters" aria-label="Hurtigfiltre">
<div class="d-flex flex-wrap align-items-center gap-2 mb-3"> <button class="sag-quick-filter {{ 'active' if current_quick_filter == 'all' else '' }}" type="button" data-quick-filter="all"><i class="bi bi-grid"></i>Alle aktive</button>
<div class="filter-pills"> <button class="sag-quick-filter {{ 'active' if current_quick_filter == 'mine-open' else '' }}" type="button" data-quick-filter="mine-open"><i class="bi bi-person-check"></i>Mine åbne sager</button>
<div class="filter-pill active" data-filter="all">Alle</div> <button class="sag-quick-filter {{ 'active' if current_quick_filter == 'overdue' else '' }}" type="button" data-quick-filter="overdue"><i class="bi bi-alarm"></i>Overskredet deadline</button>
<div class="filter-pill" data-filter="åben">Åbne</div> <button class="sag-quick-filter {{ 'active' if current_quick_filter == 'my-groups' else '' }}" type="button" data-quick-filter="my-groups"><i class="bi bi-people"></i>Mine grupper</button>
<div class="filter-pill" data-filter="lukket">Lukkede</div> <button class="sag-quick-filter {{ 'active' if current_quick_filter == 'unassigned' else '' }}" type="button" data-quick-filter="unassigned"><i class="bi bi-person-dash"></i>Ikke tildelt</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'closed' else '' }}" type="button" data-quick-filter="closed"><i class="bi bi-check2-circle"></i>Lukkede</button>
</div> </div>
</div>
<div class="sag-advanced-filters">
<div class="type-filter-wrap"> <div class="type-filter-wrap">
<div class="dropdown type-filter-dropdown mb-1"> <div class="dropdown type-filter-dropdown mb-1">
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false"> <button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
@ -746,6 +929,19 @@
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}"> <a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %} {% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
</a> </a>
<div class="dropdown ms-auto">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-layout-three-columns me-1"></i>Kolonner
</button>
<div class="dropdown-menu dropdown-menu-end p-2 shadow">
<div class="small text-muted px-2 pb-2">Træk for at ændre rækkefølge</div>
<div id="sagColumnList" class="sag-column-list"></div>
<div class="d-flex gap-2 border-top pt-2 mt-2">
<button id="resetSagColumnsBtn" class="btn btn-sm btn-outline-secondary" type="button">Nulstil</button>
<button id="saveSagColumnsBtn" class="btn btn-sm btn-primary ms-auto" type="button">Gem for mig</button>
</div>
</div>
</div>
</div> </div>
<!-- Table --> <!-- Table -->
@ -780,14 +976,15 @@
data-status="{{ sag.status }}" data-status="{{ sag.status }}"
data-type="{{ sag.template_key or sag.type or 'ticket' }}" data-type="{{ sag.template_key or sag.type or 'ticket' }}"
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}" data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}"> data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}"
data-deadline="{{ sag.deadline.isoformat() if sag.deadline else '' }}">
<td class="col-expand" onclick="event.stopPropagation();"> <td class="col-expand" onclick="event.stopPropagation();">
{% if has_relations %} {% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span> <span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ sag.id }}/v3'">#{{ sag.id }}</span> <a class="sag-id" href="/sag/{{ sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ sag.id }}</a>
{% if (sag.unread_email_count or 0) > 0 %} {% if (sag.unread_email_count or 0) > 0 %}
{% set unread_level = sag.unread_email_level or 'fresh' %} {% set unread_level = sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails"> <span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
@ -796,10 +993,10 @@
{% endif %} {% endif %}
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }} {% if sag.customer_id and sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.customer_name }}"><i class="bi bi-building"></i><span>{{ sag.customer_name }}</span></a>{% else %}-{% endif %}
</td> </td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }} {% if sag.kontakt_id and sag.kontakt_navn and sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
</td> </td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'"> <td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div> <div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
@ -812,13 +1009,15 @@
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'"> <td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% set status_raw = sag.status if sag.status else 'åben' %} {% set status_raw = sag.status if sag.status else 'åben' %}
{% set status_class = status_raw|lower|replace(' ', '-') %} <select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'status', this.value, this)">
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span> {% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
</select>
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'"> <td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<div class="owner-cell"> <select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
{{ initials_bubble(sag.ansvarlig_navn) }} <option value="">Ikke tildelt</option>
</div> {% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
</select>
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
<div class="owner-cell"> <div class="owner-cell">
@ -855,10 +1054,10 @@
{% if related_sag and rel.target_id not in seen_targets %} {% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %} {% 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 %} {% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" style="display: none;"> <tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" data-deadline="{{ related_sag.deadline.isoformat() if related_sag.deadline else '' }}" style="display: none;">
<td class="col-expand"><span class="child-branch"></span></td> <td class="col-expand"><span class="child-branch"></span></td>
<td> <td>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">#{{ related_sag.id }}</span> <a class="sag-id" href="/sag/{{ related_sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ related_sag.id }}</a>
{% if (related_sag.unread_email_count or 0) > 0 %} {% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %} {% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails"> <span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
@ -867,10 +1066,10 @@
{% endif %} {% endif %}
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }} {% if related_sag.customer_id and related_sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ related_sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.customer_name }}"><i class="bi bi-building"></i><span>{{ related_sag.customer_name }}</span></a>{% else %}-{% endif %}
</td> </td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }} {% if related_sag.kontakt_id and related_sag.kontakt_navn and related_sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ related_sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ related_sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
</td> </td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'"> <td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% for rt in all_rel_types %} {% for rt in all_rel_types %}
@ -886,13 +1085,15 @@
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %} {% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
{% set related_status_class = related_status_raw|lower|replace(' ', '-') %} <select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ related_status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'status', this.value, this)">
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span> {% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == related_status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
</select>
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'"> <td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
<div class="owner-cell"> <select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ related_sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
{{ initials_bubble(related_sag.ansvarlig_navn) }} <option value="">Ikke tildelt</option>
</div> {% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if related_sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
</select>
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
<div class="owner-cell"> <div class="owner-cell">
@ -938,9 +1139,127 @@
</div> </div>
</div> </div>
<div class="modal fade" id="closeCaseWithoutTimeModal" tabindex="-1" aria-labelledby="closeCaseWithoutTimeTitle" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-danger text-white border-0 py-4">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-exclamation-triangle-fill" style="font-size:3rem;line-height:1"></i>
<div>
<div class="text-uppercase fw-bold small opacity-75 mb-1">Vigtig advarsel</div>
<h2 class="modal-title fw-bold mb-0" id="closeCaseWithoutTimeTitle">Der er ikke registreret tid på sagen</h2>
</div>
</div>
</div>
<div class="modal-body p-4 p-md-5 text-center">
<p class="fs-4 fw-semibold mb-3">Du er ved at lukke sag <span id="closeWithoutTimeCaseNumber"></span> uden tidsregistrering.</p>
<p class="fs-5 text-muted mb-0">Kontrollér, at det er korrekt. Sagen bliver kun lukket, hvis du aktivt bekræfter nedenfor.</p>
</div>
<div class="modal-footer border-0 bg-light p-4 justify-content-center gap-2">
<button type="button" class="btn btn-lg btn-outline-secondary px-4" data-bs-dismiss="modal">
<i class="bi bi-arrow-left me-2"></i>Gå tilbage
</button>
<button type="button" class="btn btn-lg btn-danger px-4" id="confirmCloseCaseWithoutTimeBtn">
<i class="bi bi-check-circle-fill me-2"></i>Ja, luk sagen uden tid
</button>
</div>
</div>
</div>
</div>
<script> <script>
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }}; const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
function applyStatusSelectTone(control) {
if (!control) return;
const status = String(control.value || '').trim().toLowerCase();
control.classList.remove('status-tone-open', 'status-tone-progress', 'status-tone-waiting', 'status-tone-done');
let tone = 'status-tone-open';
if (['under behandling', 'i gang', 'in progress'].includes(status)) tone = 'status-tone-progress';
else if (['afventer', 'on hold'].includes(status)) tone = 'status-tone-waiting';
else if (['løst', 'lukket', 'afsluttet', 'resolved', 'closed', 'done'].includes(status)) tone = 'status-tone-done';
control.classList.add(tone);
}
document.querySelectorAll('.sag-status-select').forEach(applyStatusSelectTone);
function confirmCloseCaseWithoutTime(caseId, message) {
return new Promise(resolve => {
const modalElement = document.getElementById('closeCaseWithoutTimeModal');
const confirmButton = document.getElementById('confirmCloseCaseWithoutTimeBtn');
const caseNumber = document.getElementById('closeWithoutTimeCaseNumber');
if (!modalElement || !confirmButton || typeof bootstrap === 'undefined') {
resolve(window.confirm(`${message}\n\nVil du lukke sagen uden tidsregistrering?`));
return;
}
if (caseNumber) caseNumber.textContent = `#${caseId}`;
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
let confirmed = false;
const handleConfirm = () => {
confirmed = true;
modal.hide();
};
const handleHidden = () => {
confirmButton.removeEventListener('click', handleConfirm);
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
resolve(confirmed);
};
confirmButton.addEventListener('click', handleConfirm, { once: true });
modalElement.addEventListener('hidden.bs.modal', handleHidden, { once: true });
modal.show();
});
}
async function updateCaseListField(caseId, field, value, control) {
const previous = control?.dataset?.previous ?? '';
if (control) control.disabled = true;
try {
const saveField = async (confirmedWithoutTime = false) => {
const body = { [field]: value === '' ? null : value };
if (confirmedWithoutTime) body.confirm_close_without_time = true;
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
};
let { response, payload } = await saveField();
const detail = payload?.detail;
if (response.status === 409 && detail?.code === 'close_without_time_confirmation_required') {
const confirmed = await confirmCloseCaseWithoutTime(caseId, detail.message);
if (!confirmed) {
if (control) control.value = previous;
if (control && field === 'status') applyStatusSelectTone(control);
return;
}
({ response, payload } = await saveField(true));
}
if (!response.ok) {
const message = typeof payload.detail === 'string' ? payload.detail : payload.detail?.message;
throw new Error(message || 'Ændringen kunne ikke gemmes');
}
if (control) control.dataset.previous = String(value ?? '');
if (control && field === 'status') applyStatusSelectTone(control);
const row = control?.closest('tr');
if (row && field === 'status') row.dataset.status = String(value || '');
if (row && field === 'status') updateOverdueDeadlineMarker(row);
if (row && field === 'ansvarlig_bruger_id') row.dataset.assigneeId = String(value || '');
if (typeof applyFilters === 'function') applyFilters();
} catch (error) {
if (control) control.value = previous;
if (control && field === 'status') applyStatusSelectTone(control);
if (typeof showNotification === 'function') showNotification(error.message, 'error');
else window.alert(error.message);
} finally {
if (control) control.disabled = false;
}
}
function escapeTopAlertHtml(value) { function escapeTopAlertHtml(value) {
return String(value ?? '') return String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -1026,11 +1345,35 @@
const allRows = document.querySelectorAll('.tree-row'); const allRows = document.querySelectorAll('.tree-row');
let currentSearch = ''; let currentSearch = '';
let currentFilter = 'all'; let currentFilter = 'all';
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
let currentEmployeeId = '';
let currentEmployeeGroupIds = new Set();
let currentTypes = new Set(); let currentTypes = new Set();
let currentAssignees = new Set(); let currentAssignees = new Set();
let currentGroups = new Set(); let currentGroups = new Set();
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }}); const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
function isDeadlinePast(deadlineValue) {
if (!deadlineValue) return false;
const deadline = new Date(deadlineValue);
if (Number.isNaN(deadline.getTime())) return false;
deadline.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
return deadline.getTime() < today.getTime();
}
function updateOverdueDeadlineMarker(row) {
if (!row) return;
const status = String(row.dataset.status || '').trim().toLowerCase();
const deadlineValue = String(row.dataset.deadline || '').trim();
const isOverdue = isDeadlinePast(deadlineValue)
&& !closedStatuses.has(status);
row.classList.toggle('sag-deadline-overdue', isOverdue);
}
document.querySelectorAll('.sag-table tbody tr[data-deadline]').forEach(updateOverdueDeadlineMarker);
const assigneeFilter = document.getElementById('assigneeFilter'); const assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter'); const groupFilter = document.getElementById('groupFilter');
const assigneeFilterList = document.getElementById('assigneeFilterList'); const assigneeFilterList = document.getElementById('assigneeFilterList');
@ -1043,6 +1386,20 @@
function applyFilters() { function applyFilters() {
const search = currentSearch; const search = currentSearch;
const matchesQuickFilter = (row, isClosed) => {
const assigneeId = String(row.dataset.assigneeId || '').trim();
const groupId = String(row.dataset.groupId || '').trim();
if (currentQuickFilter === 'mine-open') return !isClosed && !!currentEmployeeId && assigneeId === currentEmployeeId;
if (currentQuickFilter === 'my-groups') return !isClosed && currentEmployeeGroupIds.has(groupId);
if (currentQuickFilter === 'unassigned') return !isClosed && !assigneeId;
if (currentQuickFilter === 'closed') return isClosed;
if (currentQuickFilter === 'overdue') {
const deadline = String(row.dataset.deadline || '');
return !isClosed && isDeadlinePast(deadline);
}
return !isClosed;
};
allRows.forEach(row => { allRows.forEach(row => {
const text = row.textContent.toLowerCase(); const text = row.textContent.toLowerCase();
const status = String(row.dataset.status || '').toLowerCase(); const status = String(row.dataset.status || '').toLowerCase();
@ -1051,7 +1408,7 @@
const groupRaw = String(row.dataset.groupId || '').trim(); const groupRaw = String(row.dataset.groupId || '').trim();
const assigneeId = assigneeRaw || '__UNASSIGNED__'; const assigneeId = assigneeRaw || '__UNASSIGNED__';
const groupId = groupRaw; const groupId = groupRaw;
const matchesSearch = text.includes(search); const matchesSearch = search.split(/\s+/).filter(Boolean).every(term => text.includes(term));
const isClosed = closedStatuses.has(status); const isClosed = closedStatuses.has(status);
const matchesFilter = currentFilter === 'all' const matchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !isClosed) || (currentFilter === 'åben' && !isClosed)
@ -1060,7 +1417,7 @@
const matchesType = currentTypes.size === 0 || currentTypes.has(type); const matchesType = currentTypes.size === 0 || currentTypes.has(type);
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId); const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId); const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup; const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup && matchesQuickFilter(row, isClosed);
row.style.display = visible ? '' : 'none'; row.style.display = visible ? '' : 'none';
@ -1075,7 +1432,7 @@
const childGroupRaw = String(child.dataset.groupId || '').trim(); const childGroupRaw = String(child.dataset.groupId || '').trim();
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__'; const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
const childGroupId = childGroupRaw; const childGroupId = childGroupRaw;
const childMatchesSearch = childText.includes(search); const childMatchesSearch = search.split(/\s+/).filter(Boolean).every(term => childText.includes(term));
const childIsClosed = closedStatuses.has(childStatus); const childIsClosed = closedStatuses.has(childStatus);
const childMatchesFilter = currentFilter === 'all' const childMatchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !childIsClosed) || (currentFilter === 'åben' && !childIsClosed)
@ -1084,7 +1441,7 @@
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType); const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId); const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId); const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup; const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup && matchesQuickFilter(child, childIsClosed);
child.style.display = childVisible ? '' : 'none'; child.style.display = childVisible ? '' : 'none';
}); });
} }
@ -1195,11 +1552,55 @@
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', function(e) { searchInput.addEventListener('input', function(e) {
currentSearch = e.target.value.toLowerCase(); currentSearch = e.target.value.trim().toLowerCase();
document.getElementById('clearSearchBtn')?.classList.toggle('d-none', !currentSearch);
applyFilters(); applyFilters();
}); });
} }
document.getElementById('clearSearchBtn')?.addEventListener('click', () => {
searchInput.value = '';
currentSearch = '';
document.getElementById('clearSearchBtn')?.classList.add('d-none');
searchInput.focus();
applyFilters();
});
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
event.preventDefault();
searchInput?.focus();
}
if (event.key === 'Escape' && document.activeElement === searchInput && searchInput.value) {
document.getElementById('clearSearchBtn')?.click();
}
});
document.querySelectorAll('.sag-quick-filter').forEach(button => {
button.addEventListener('click', () => {
const quick = button.dataset.quickFilter || 'all';
const url = new URL(window.location.href);
if (quick === 'all') url.searchParams.delete('quick');
else url.searchParams.set('quick', quick);
url.searchParams.delete('status');
window.location.assign(url.toString());
});
});
async function loadQuickFilterContext() {
try {
const response = await fetch('/api/v1/sag/me/quick-filter-context', { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
currentEmployeeId = String(data.user_id || '');
currentEmployeeGroupIds = new Set((data.group_ids || []).map(String));
applyFilters();
} catch (error) {
console.error('Kunne ikke hente hurtigfilter-kontekst', error);
}
}
loadQuickFilterContext();
// Filter functionality // Filter functionality
const filterPills = document.querySelectorAll('.filter-pill'); const filterPills = document.querySelectorAll('.filter-pill');
@ -1328,6 +1729,121 @@
} }
} }
const SAG_COLUMN_DEFINITIONS = [
['id', 'SagsID'], ['company', 'Virksomhed'], ['contact', 'Kontakt'],
['description', 'Beskrivelse'], ['type', 'Type'], ['priority', 'Prioritet'],
['status', 'Status'], ['owner', 'Ansvarlig'], ['group', 'Gruppe/Level'],
['next_todo', 'Næste todo'], ['created', 'Oprettet'], ['start', 'Arbejdsstart'],
['deferred', 'Start senest'], ['deadline', 'Deadline']
];
const SAG_DEFAULT_COLUMN_ORDER = SAG_COLUMN_DEFINITIONS.map(([key]) => key);
let sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
let sagHiddenColumns = new Set();
function normalizeSagColumnOrder(order) {
const allowed = new Set(SAG_DEFAULT_COLUMN_ORDER);
const normalized = Array.from(new Set((Array.isArray(order) ? order : []).filter(key => allowed.has(key))));
SAG_DEFAULT_COLUMN_ORDER.forEach(key => { if (!normalized.includes(key)) normalized.push(key); });
return normalized;
}
function applySagColumnPreferences() {
document.querySelectorAll('.sag-table tr').forEach(row => {
const cells = Array.from(row.children).filter(cell => cell.matches('th,td'));
if (!cells.length) return;
const expandCell = cells.find(cell => cell.dataset.columnKey === 'expand') || cells[0];
if (!expandCell.dataset.columnKey) {
expandCell.dataset.columnKey = 'expand';
cells.slice(1).forEach((cell, index) => {
if (SAG_DEFAULT_COLUMN_ORDER[index]) cell.dataset.columnKey = SAG_DEFAULT_COLUMN_ORDER[index];
});
}
const byKey = new Map(Array.from(row.children).filter(cell => cell.dataset.columnKey).map(cell => [cell.dataset.columnKey, cell]));
if (byKey.get('expand')) row.appendChild(byKey.get('expand'));
sagColumnOrder.forEach(key => {
const cell = byKey.get(key);
if (!cell) return;
cell.style.display = sagHiddenColumns.has(key) ? 'none' : '';
row.appendChild(cell);
});
});
}
function renderSagColumnChooser() {
const host = document.getElementById('sagColumnList');
if (!host) return;
const labels = Object.fromEntries(SAG_COLUMN_DEFINITIONS);
host.innerHTML = sagColumnOrder.map(key => `
<div class="sag-column-item" draggable="true" data-column-key="${key}">
<i class="bi bi-grip-vertical drag-handle"></i>
<input class="form-check-input sag-column-visible" type="checkbox" ${sagHiddenColumns.has(key) ? '' : 'checked'}>
<span>${labels[key]}</span>
</div>`).join('');
}
function applySagColumnPayload(data) {
sagColumnOrder = normalizeSagColumnOrder(data?.column_order);
sagHiddenColumns = new Set((Array.isArray(data?.hidden_columns) ? data.hidden_columns : []).filter(key => SAG_DEFAULT_COLUMN_ORDER.includes(key)));
renderSagColumnChooser();
applySagColumnPreferences();
}
async function saveSagColumnPreferences() {
const button = document.getElementById('saveSagColumnsBtn');
if (button) button.disabled = true;
try {
const response = await fetch('/api/v1/sag/me/list-preferences', {
method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type_filters: getSelectedTypesFromUi(),
column_order: sagColumnOrder,
hidden_columns: Array.from(sagHiddenColumns)
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Kolonnerne kunne ikke gemmes');
applySagColumnPayload(payload);
if (typeof showNotification === 'function') showNotification('Kolonner gemt for din bruger', 'success');
} catch (error) {
if (typeof showNotification === 'function') showNotification(error.message, 'error');
} finally {
if (button) button.disabled = false;
}
}
const sagColumnList = document.getElementById('sagColumnList');
let draggedSagColumn = null;
sagColumnList?.addEventListener('change', event => {
const item = event.target.closest('.sag-column-item');
if (!item || !event.target.matches('.sag-column-visible')) return;
if (event.target.checked) sagHiddenColumns.delete(item.dataset.columnKey);
else sagHiddenColumns.add(item.dataset.columnKey);
applySagColumnPreferences();
});
sagColumnList?.addEventListener('dragstart', event => {
draggedSagColumn = event.target.closest('.sag-column-item')?.dataset.columnKey || null;
event.target.closest('.sag-column-item')?.classList.add('dragging');
});
sagColumnList?.addEventListener('dragend', event => event.target.closest('.sag-column-item')?.classList.remove('dragging'));
sagColumnList?.addEventListener('dragover', event => event.preventDefault());
sagColumnList?.addEventListener('drop', event => {
event.preventDefault();
const targetKey = event.target.closest('.sag-column-item')?.dataset.columnKey;
if (!draggedSagColumn || !targetKey || draggedSagColumn === targetKey) return;
sagColumnOrder = sagColumnOrder.filter(key => key !== draggedSagColumn);
sagColumnOrder.splice(sagColumnOrder.indexOf(targetKey), 0, draggedSagColumn);
draggedSagColumn = null;
renderSagColumnChooser();
applySagColumnPreferences();
});
document.getElementById('resetSagColumnsBtn')?.addEventListener('click', () => {
sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
sagHiddenColumns = new Set();
renderSagColumnChooser();
applySagColumnPreferences();
});
document.getElementById('saveSagColumnsBtn')?.addEventListener('click', saveSagColumnPreferences);
async function loadTypeFilterPreferences() { async function loadTypeFilterPreferences() {
try { try {
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' }); const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
@ -1335,6 +1851,7 @@
const data = await res.json(); const data = await res.json();
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : []; const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean)); currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
applySagColumnPayload(data);
applySelectedTypesToUi(); applySelectedTypesToUi();
applyFilters(); applyFilters();
} catch (err) { } catch (err) {
@ -1351,7 +1868,7 @@
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type_filters: selected }), body: JSON.stringify({ type_filters: selected, column_order: sagColumnOrder, hidden_columns: Array.from(sagHiddenColumns) }),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {

View File

@ -0,0 +1,18 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{{ article.title }} - Viden{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:980px">
<div class="d-flex justify-content-between align-items-center mb-4"><a href="/knowledge" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Vidensdatabase</a><a href="/sag/{{ article.sag_id }}/v3#solution" class="btn btn-outline-primary"><i class="bi bi-folder2-open me-1"></i>Kildesag</a></div>
<article class="card border-0 shadow-sm rounded-4"><div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap gap-2 mb-3"><span class="badge text-bg-success">Godkendt</span><span class="badge text-bg-light border">Version {{ article.version_number }}</span><span class="badge text-bg-light border">{{ 'Generel' if article.visibility == 'general' else ('Kundespecifik' if article.visibility == 'customer' else 'Intern') }}</span></div>
<h1 class="display-6 fw-bold">{{ article.title }}</h1><p class="lead text-muted">{{ article.summary or '' }}</p><hr class="my-4">
{% if article.problem %}<section class="mb-4"><h2 class="h5"><i class="bi bi-exclamation-circle text-danger me-2"></i>Problem og symptomer</h2><div style="white-space:pre-wrap">{{ article.problem }}</div></section>{% endif %}
{% if article.root_cause %}<section class="mb-4"><h2 class="h5"><i class="bi bi-diagram-3 text-warning me-2"></i>Årsag</h2><div style="white-space:pre-wrap">{{ article.root_cause }}</div></section>{% endif %}
{% if article.investigation %}<section class="mb-4"><h2 class="h5"><i class="bi bi-search text-info me-2"></i>Undersøgelse</h2><div style="white-space:pre-wrap">{{ article.investigation }}</div></section>{% endif %}
<section class="p-4 rounded-4 bg-success-subtle mb-4"><h2 class="h5 text-success-emphasis"><i class="bi bi-check-circle me-2"></i>Endelig løsning</h2><div style="white-space:pre-wrap">{{ article.solution }}</div></section>
{% if article.workaround %}<section class="mb-4"><h2 class="h5"><i class="bi bi-cone-striped text-primary me-2"></i>Workaround</h2><div style="white-space:pre-wrap">{{ article.workaround }}</div></section>{% endif %}
<div class="d-flex flex-wrap gap-2 mt-4">{% for tag in article.tags or [] %}<span class="badge rounded-pill text-bg-light border">{{ tag }}</span>{% endfor %}{% for product in article.products or [] %}<span class="badge rounded-pill text-bg-primary">{{ product }}</span>{% endfor %}</div>
<hr class="my-4"><div class="small text-muted">Udgivet af {{ article.published_by or 'ukendt' }}{% if article.customer_name %} · Kun {{ article.customer_name }}{% endif %} · Kilde: sag {{ article.sag_id }}</div>
</div></article>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Vidensdatabase - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.kb-hero{background:linear-gradient(135deg,#0f4c75,#2563eb);color:#fff;border-radius:22px;padding:2rem;box-shadow:0 18px 45px rgba(15,76,117,.2)}
.kb-search{border:0;border-radius:14px;min-height:56px;padding-left:3.1rem;box-shadow:0 8px 24px rgba(15,23,42,.14)}
.kb-card{border:1px solid rgba(15,76,117,.12);border-radius:16px;transition:.18s ease;background:var(--bg-card,#fff)}
.kb-card:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(15,76,117,.12);border-color:rgba(37,99,235,.3)}
.kb-card a{text-decoration:none;color:inherit}.kb-tag{background:rgba(37,99,235,.09);color:#1d4ed8;border-radius:999px;padding:.25rem .6rem;font-size:.75rem}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4">
<section class="kb-hero mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 align-items-start">
<div><div class="small text-uppercase opacity-75 fw-semibold">BMC Viden</div><h1 class="h2 fw-bold mb-2">Find en gennemprøvet løsning</h1><p class="mb-0 opacity-75">Kun godkendte og udgivne løsninger vises her.</p></div>
<a href="/sag" class="btn btn-light"><i class="bi bi-arrow-left me-1"></i>Sager</a>
</div>
<div class="position-relative mt-4"><i class="bi bi-search position-absolute top-50 translate-middle-y ms-3 fs-5 text-secondary"></i><input id="kbSearch" class="form-control kb-search" type="search" placeholder="Søg efter problem, fejlbesked, produkt eller løsning…" autocomplete="off"></div>
</section>
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0">Vidensartikler</h2><span id="kbCount" class="badge rounded-pill text-bg-light border">Henter…</span></div>
<div id="kbState" class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm me-2"></div>Henter viden…</div>
<div id="kbResults" class="row g-3"></div>
</div>
<script>
(() => {
const input=document.getElementById('kbSearch'), results=document.getElementById('kbResults'), state=document.getElementById('kbState'), count=document.getElementById('kbCount'); let timer, controller;
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
async function load(){ controller?.abort(); controller=new AbortController(); const q=input.value.trim(); state.classList.remove('d-none'); state.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Søger…'; results.innerHTML='';
try{const r=await fetch(`/api/v1/knowledge/articles?q=${encodeURIComponent(q)}`,{signal:controller.signal,credentials:'include'});if(!r.ok)throw new Error('Kunne ikke hente vidensartikler');const d=await r.json();count.textContent=`${d.total} artikler`;state.classList.toggle('d-none',d.items.length>0);if(!d.items.length)state.innerHTML='<i class="bi bi-journal-x fs-1 d-block mb-2"></i>Ingen godkendte artikler matcher søgningen.';
results.innerHTML=d.items.map(a=>`<div class="col-xl-4 col-md-6"><article class="kb-card h-100 p-4"><a href="/knowledge/${a.id}"><div class="d-flex justify-content-between gap-2 mb-2"><span class="small text-uppercase text-primary fw-semibold">${a.visibility==='general'?'Generel':a.visibility==='customer'?'Kundespecifik':'Intern'}</span><span class="small text-muted">v${a.version_number}</span></div><h3 class="h5 fw-bold">${esc(a.title)}</h3><p class="text-muted mb-3">${esc(a.summary||'Ingen kort beskrivelse')}</p><div class="d-flex flex-wrap gap-1">${(a.tags||[]).slice(0,5).map(t=>`<span class="kb-tag">${esc(t)}</span>`).join('')}</div><div class="small text-muted mt-3"><i class="bi bi-folder2-open me-1"></i>Sag ${a.sag_id}${a.customer_name?' · '+esc(a.customer_name):''}</div></a></article></div>`).join('');
}catch(e){if(e.name==='AbortError')return;state.classList.remove('d-none');state.innerHTML=`<i class="bi bi-exclamation-triangle text-danger fs-2 d-block"></i>${esc(e.message)}`;count.textContent='Fejl';}}
input.addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -145,7 +145,7 @@
<input type="date" class="form-control" id="ordersDateTo"> <input type="date" class="form-control" id="ordersDateTo">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button> <button class="btn btn-outline-secondary w-100" onclick="resetOrderFilters()"><i class="bi bi-x-lg me-1"></i>Ryd filtre</button>
</div> </div>
<div class="col-md-3 text-end"> <div class="col-md-3 text-end">
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span> <span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
@ -358,7 +358,26 @@
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum); document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
} }
let ordersFilterTimer = null;
function resetOrderFilters() {
['ordersSearch', 'ordersStatus', 'ordersCaseId', 'ordersCustomerId', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
const element = document.getElementById(id);
if (element) element.value = '';
});
loadOrders();
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
['ordersStatus', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
document.getElementById(id)?.addEventListener('change', loadOrders);
});
['ordersSearch', 'ordersCaseId', 'ordersCustomerId'].forEach(id => {
document.getElementById(id)?.addEventListener('input', () => {
window.clearTimeout(ordersFilterTimer);
ordersFilterTimer = window.setTimeout(loadOrders, 300);
});
});
loadOrders(); loadOrders();
}); });
</script> </script>

View File

@ -273,7 +273,7 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s" query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}: if not result and key in {"case_types", "case_type_module_defaults", "case_statuses", "time_multiplier_presets"}:
seed_query = """ seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public) INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
@ -325,6 +325,19 @@ async def get_setting(key: str):
) )
) )
if key == "time_multiplier_presets":
execute_query(
seed_query,
(
"time_multiplier_presets",
'[{"label":"Haster","text":"Haster","multiplier":3},{"label":"Avanceret netværk","text":"Avanceret netværk","multiplier":2},{"label":"Haster + ava. network","text":"Haster + ava. network","multiplier":6}]',
"system",
"Valgbare multiplikator presets til tidsregistrering",
"json",
True,
),
)
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result and key == "email_default_signature_template": if not result and key == "email_default_signature_template":

View File

@ -228,6 +228,12 @@
display: none; display: none;
} }
.global-bottom-bar .bb-activity-chip.is-paused {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: #9a6700;
}
.global-bottom-bar .bb-notification-count { .global-bottom-bar .bb-notification-count {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@ -1591,7 +1597,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.5"></script> <script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/sms.js?v=1.1"></script> <script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script> <script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.65"></script> <script src="/static/js/bottom-bar.js?v=2.68"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true; window.BMC_CAN_CLICK_TO_CALL = true;

View File

@ -549,7 +549,11 @@ async def get_subscription_change_request_by_case(
(change_sag_id,), (change_sag_id,),
) )
if not change: if not change:
raise HTTPException(status_code=404, detail="Change request not found") return {
"change_request": None,
"items": [],
"allowed_actions": _permissions_for(current_user),
}
items = execute_query( items = execute_query(
"""SELECT ci.*, s.subscription_number, s.product_name """SELECT ci.*, s.subscription_number, s.product_name
FROM subscription_change_request_items ci FROM subscription_change_request_items ci

View File

@ -142,7 +142,10 @@ def _elapsed_minutes_excluding_pause(entry: Dict[str, Any], end: datetime) -> in
paused_seconds += _seconds_between(paused_at, end) paused_seconds += _seconds_between(paused_at, end)
effective_seconds = max(0, total_seconds - paused_seconds) effective_seconds = max(0, total_seconds - paused_seconds)
return effective_seconds // 60 # A running timer represents actual work as soon as at least one second has
# elapsed. Flooring sub-minute timers to zero later produced an invalid
# approved_hours=0 value when the timer was stopped.
return (effective_seconds + 59) // 60 if effective_seconds else 0
def _pause_total_seconds_at(entry: Dict[str, Any], end: datetime) -> int: def _pause_total_seconds_at(entry: Dict[str, Any], end: datetime) -> int:
@ -2559,7 +2562,7 @@ async def stop_live_timer_v1(
faktisk_tid_min = %s, faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END, fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01), original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END, approved_hours = CASE WHEN billable AND %s::numeric > 0 THEN (%s::numeric / 60.0) ELSE NULL END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END, rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
worked_date = COALESCE(worked_date, %s), worked_date = COALESCE(worked_date, %s),
entry_status = %s, entry_status = %s,
@ -2575,6 +2578,7 @@ async def stop_live_timer_v1(
billable_minutes, billable_minutes,
actual_minutes, actual_minutes,
billable_minutes, billable_minutes,
billable_minutes,
block_minutes, block_minutes,
now.date(), now.date(),
entry_status, entry_status,

View File

@ -115,6 +115,7 @@ async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"), search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
category: Optional[str] = Query(None, description="Filter by category"), category: Optional[str] = Query(None, description="Filter by category"),
is_active: Optional[bool] = Query(None, description="Filter by active status"), is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_internet_provider: Optional[bool] = Query(None, description="Filter internet providers"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100) limit: int = Query(50, ge=1, le=100)
): ):
@ -135,6 +136,10 @@ async def list_vendors(
query += " AND is_active = %s" query += " AND is_active = %s"
params.append(is_active) params.append(is_active)
if is_internet_provider is not None:
query += " AND is_internet_provider = %s"
params.append(is_internet_provider)
query += " ORDER BY name LIMIT %s OFFSET %s" query += " ORDER BY name LIMIT %s OFFSET %s"
params.extend([limit, skip]) params.extend([limit, skip])
@ -218,31 +223,69 @@ async def get_vendor_invoices(vendor_id: int):
return rows or [] return rows or []
@router.get("/vendors/{vendor_id}/internet-connections", tags=["Vendors"])
async def get_vendor_internet_connections(vendor_id: int):
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
return execute_query(
"""
SELECT ic.id, ic.name, ic.circuit_number, ic.address, ic.status,
ic.download_mbps, ic.upload_mbps, ic.monthly_cost, ic.sales_price,
ic.customer_id, c.name AS customer_name, ic.updated_at
FROM internet_connections_connections ic
LEFT JOIN customers c ON c.id = ic.customer_id
WHERE ic.vendor_id = %s AND ic.deleted_at IS NULL
ORDER BY ic.name ASC, ic.id ASC
""",
(vendor_id,),
) or []
@router.post("/vendors", response_model=Vendor, tags=["Vendors"]) @router.post("/vendors", response_model=Vendor, tags=["Vendors"])
async def create_vendor(vendor: VendorCreate): async def create_vendor(vendor: VendorCreate):
"""Create a new vendor""" """Create a new vendor"""
name = str(vendor.name or "").strip()
cvr_number = str(vendor.cvr_number or "").strip() or None
if not name:
raise HTTPException(status_code=422, detail="Virksomhedsnavn er påkrævet")
if cvr_number and (len(cvr_number) != 8 or not cvr_number.isdigit()):
raise HTTPException(status_code=422, detail="CVR-nummer skal bestå af 8 cifre")
duplicate = execute_query_single(
"""SELECT id, name FROM vendors
WHERE LOWER(BTRIM(name)) = LOWER(BTRIM(%s))
OR (%s::text IS NOT NULL AND cvr_number = %s)
ORDER BY id LIMIT 1""",
(name, cvr_number, cvr_number),
)
if duplicate:
raise HTTPException(
status_code=409,
detail=f"Leverandøren findes allerede: {duplicate['name']} (#{duplicate['id']})",
)
try: try:
query = """ query = """
INSERT INTO vendors ( INSERT INTO vendors (
name, cvr_number, email, phone, address, postal_code, city, name, cvr_number, email, phone, address, postal_code, city,
website, domain, email_pattern, category, priority, notes, is_active website, domain, email_pattern, category, priority, notes, is_active, is_internet_provider
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
params = ( params = (
vendor.name, vendor.cvr_number, vendor.email, vendor.phone, name, cvr_number, vendor.email, vendor.phone,
vendor.address, vendor.postal_code, vendor.city, vendor.website, vendor.address, vendor.postal_code, vendor.city, vendor.website,
vendor.domain, vendor.email_pattern, vendor.category, vendor.priority, vendor.domain, vendor.email_pattern, vendor.category, vendor.priority,
vendor.notes, vendor.is_active vendor.notes, vendor.is_active, vendor.is_internet_provider
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result or len(result) == 0: if not result or len(result) == 0:
raise HTTPException(status_code=500, detail="Failed to create vendor") raise HTTPException(status_code=500, detail="Failed to create vendor")
logger.info(f"✅ Created vendor: {vendor.name}") logger.info(f"✅ Created vendor: {name}")
return result[0] return result[0]
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Error creating vendor: {e}") logger.error(f"❌ Error creating vendor: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -272,6 +315,11 @@ async def update_vendor(vendor_id: int, vendor: VendorUpdate):
if vendor.phone is not None: if vendor.phone is not None:
update_fields.append("phone = %s") update_fields.append("phone = %s")
params.append(vendor.phone) params.append(vendor.phone)
for field in ("address", "postal_code", "city", "website", "economic_supplier_number"):
value = getattr(vendor, field)
if value is not None:
update_fields.append(f"{field} = %s")
params.append(value)
if vendor.address is not None: if vendor.address is not None:
update_fields.append("address = %s") update_fields.append("address = %s")
params.append(vendor.address) params.append(vendor.address)
@ -302,6 +350,9 @@ async def update_vendor(vendor_id: int, vendor: VendorUpdate):
if vendor.is_active is not None: if vendor.is_active is not None:
update_fields.append("is_active = %s") update_fields.append("is_active = %s")
params.append(vendor.is_active) params.append(vendor.is_active)
if vendor.is_internet_provider is not None:
update_fields.append("is_internet_provider = %s")
params.append(vendor.is_internet_provider)
if not update_fields: if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update") raise HTTPException(status_code=400, detail="No fields to update")

View File

@ -134,6 +134,9 @@
<a class="nav-link" href="#kunder" data-tab="kunder"> <a class="nav-link" href="#kunder" data-tab="kunder">
<i class="bi bi-building me-2"></i>Kunder <i class="bi bi-building me-2"></i>Kunder
</a> </a>
<a class="nav-link d-none" href="#internetforbindelser" data-tab="internetforbindelser" id="internetConnectionsNav">
<i class="bi bi-router me-2"></i>Internetforbindelser
</a>
<a class="nav-link" href="#aktivitet" data-tab="aktivitet"> <a class="nav-link" href="#aktivitet" data-tab="aktivitet">
<i class="bi bi-clock-history me-2"></i>Aktivitet <i class="bi bi-clock-history me-2"></i>Aktivitet
</a> </a>
@ -266,6 +269,21 @@
</div> </div>
</div> </div>
<div class="tab-pane fade" id="internetforbindelser">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold">Internetforbindelser</h5>
<span class="badge bg-primary" id="internetConnectionsCount">0</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead><tr><th>Forbindelse</th><th>Kredsløb</th><th>Adresse</th><th>Kunde</th><th>Hastighed</th><th>Status</th><th></th></tr></thead>
<tbody id="internetConnectionsBody"><tr><td colspan="7" class="text-center text-muted py-4">Indlæser…</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- Aktivitet Tab --> <!-- Aktivitet Tab -->
<div class="tab-pane fade" id="aktivitet"> <div class="tab-pane fade" id="aktivitet">
<div class="card p-4"> <div class="card p-4">
@ -356,6 +374,11 @@
<input class="form-check-input" type="checkbox" id="editIsActive"> <input class="form-check-input" type="checkbox" id="editIsActive">
<label class="form-check-label" for="editIsActive">Aktiv leverandør</label> <label class="form-check-label" for="editIsActive">Aktiv leverandør</label>
</div> </div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="editIsInternetProvider">
<label class="form-check-label" for="editIsInternetProvider">Internetleverandør</label>
<div class="form-text">Kan vælges som leverandør på internetforbindelser.</div>
</div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@ -575,6 +598,8 @@ function displayVendor(vendor) {
document.getElementById('vendorStatus').className = `badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}`; document.getElementById('vendorStatus').className = `badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}`;
document.getElementById('vendorDomain').innerHTML = vendor.domain ? `<i class="bi bi-globe me-2"></i>${vendor.domain}` : ''; document.getElementById('vendorDomain').innerHTML = vendor.domain ? `<i class="bi bi-globe me-2"></i>${vendor.domain}` : '';
document.getElementById('vendorCategory').innerHTML = `${getCategoryIcon(vendor.category)} ${vendor.category}`; document.getElementById('vendorCategory').innerHTML = `${getCategoryIcon(vendor.category)} ${vendor.category}`;
document.getElementById('internetConnectionsNav').classList.toggle('d-none', !vendor.is_internet_provider);
if (vendor.is_internet_provider) loadVendorInternetConnections();
// Update page title // Update page title
document.title = `${vendor.name} - BMC Hub`; document.title = `${vendor.name} - BMC Hub`;
@ -701,6 +726,28 @@ async function loadVendorInvoices() {
} }
} }
async function loadVendorInternetConnections() {
const body = document.getElementById('internetConnectionsBody');
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/internet-connections`);
if (!response.ok) throw new Error('Kunne ikke hente forbindelser');
const rows = await response.json();
document.getElementById('internetConnectionsCount').textContent = rows.length;
body.innerHTML = rows.length ? rows.map((row) => `
<tr>
<td class="fw-semibold">${escapeHtml(row.name || '-')}</td>
<td>${escapeHtml(row.circuit_number || '-')}</td>
<td>${escapeHtml(row.address || '-')}</td>
<td>${escapeHtml(row.customer_name || 'Ikke allokeret')}</td>
<td>${row.download_mbps || row.upload_mbps ? `${Number(row.download_mbps || 0)}/${Number(row.upload_mbps || 0)} Mbps` : '-'}</td>
<td><span class="badge bg-light text-dark border">${escapeHtml(row.status || '-')}</span></td>
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/economy/internet-connections/${row.id}"><i class="bi bi-box-arrow-up-right"></i></a></td>
</tr>`).join('') : '<tr><td colspan="7" class="text-center text-muted py-4">Ingen forbindelser koblet til leverandøren.</td></tr>';
} catch (error) {
body.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-4">Kunne ikke hente forbindelser.</td></tr>';
}
}
function displayInvoices(invoices) { function displayInvoices(invoices) {
const tbody = document.getElementById('invoicesTableBody'); const tbody = document.getElementById('invoicesTableBody');
const count = document.getElementById('invoiceCount'); const count = document.getElementById('invoiceCount');
@ -832,6 +879,7 @@ function editVendor() {
document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || ''; document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || '';
document.getElementById('editNotes').value = vendor.notes || ''; document.getElementById('editNotes').value = vendor.notes || '';
document.getElementById('editIsActive').checked = vendor.is_active; document.getElementById('editIsActive').checked = vendor.is_active;
document.getElementById('editIsInternetProvider').checked = Boolean(vendor.is_internet_provider);
new bootstrap.Modal(document.getElementById('editVendorModal')).show(); new bootstrap.Modal(document.getElementById('editVendorModal')).show();
}) })
@ -854,7 +902,8 @@ async function saveVendor() {
city: document.getElementById('editCity').value.trim() || null, city: document.getElementById('editCity').value.trim() || null,
economic_supplier_number: document.getElementById('editEconomicNumber').value.trim() || null, economic_supplier_number: document.getElementById('editEconomicNumber').value.trim() || null,
notes: document.getElementById('editNotes').value.trim() || null, notes: document.getElementById('editNotes').value.trim() || null,
is_active: document.getElementById('editIsActive').checked is_active: document.getElementById('editIsActive').checked,
is_internet_provider: document.getElementById('editIsInternetProvider').checked
}; };
if (!data.name) { if (!data.name) {

View File

@ -51,6 +51,28 @@
font-weight: bold; font-weight: bold;
font-size: 0.75rem; font-size: 0.75rem;
} }
.vendor-form-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 14px;
padding: 1.15rem;
background: var(--bg-card, #fff);
}
.vendor-form-section-title {
display: flex;
align-items: center;
gap: .55rem;
font-weight: 700;
margin-bottom: 1rem;
}
.vendor-type-switch {
border: 1px solid rgba(13, 110, 253, .2);
background: rgba(13, 110, 253, .05);
border-radius: 12px;
padding: 1rem 1rem 1rem 3rem;
}
</style> </style>
{% endblock %} {% endblock %}
@ -129,42 +151,80 @@
<!-- Create Vendor Modal --> <!-- Create Vendor Modal -->
<div class="modal fade" id="createVendorModal" tabindex="-1"> <div class="modal fade" id="createVendorModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Opret Ny Leverandør</h5> <div>
<div class="small text-uppercase text-muted fw-semibold">Leverandørkartotek</div>
<h5 class="modal-title">Opret ny leverandør</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="createVendorForm"> <form id="createVendorForm" novalidate>
<div class="alert d-none" id="createVendorFeedback" role="alert"></div>
<div class="row g-3">
<div class="col-lg-7">
<div class="vendor-form-section h-100">
<div class="vendor-form-section-title"><i class="bi bi-building"></i>Virksomhed</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-8"> <div class="col-md-8">
<label class="form-label">Virksomhedsnavn *</label> <label class="form-label" for="name">Virksomhedsnavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" required> <input type="text" class="form-control" id="name" required maxlength="255" autocomplete="organization" autofocus>
<div class="invalid-feedback">Angiv leverandørens navn.</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">CVR-nummer</label> <label class="form-label" for="cvr_number">CVR-nummer</label>
<input type="text" class="form-control" id="cvr_number" maxlength="8"> <div class="input-group">
<input type="text" inputmode="numeric" class="form-control" id="cvr_number" maxlength="8" pattern="[0-9]{8}" placeholder="12345678">
<button class="btn btn-outline-primary" type="button" id="vendorCvrLookupBtn" onclick="lookupVendorCvr()">Hent</button>
</div>
<div class="invalid-feedback">CVR skal bestå af 8 cifre.</div>
<div class="form-text" id="vendorCvrLookupStatus">Indtast CVR og klik Hent for autofyld.</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Email</label> <label class="form-label" for="category">Kategori</label>
<input type="email" class="form-control" id="email"> <select class="form-select" id="category">
<option value="general">Generel</option><option value="hardware">Hardware</option>
<option value="software">Software</option><option value="telecom">Telekom</option>
<option value="services">Services</option><option value="hosting">Hosting</option>
</select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Telefon</label> <label class="form-label" for="domain">Domæne</label>
<input type="text" class="form-control" id="phone"> <input type="text" class="form-control" id="domain" placeholder="example.com" autocomplete="off">
<div class="form-text">Bruges til sikkert mailmatch.</div>
</div>
<div class="col-12"><div class="form-check form-switch vendor-type-switch">
<input class="form-check-input" type="checkbox" id="is_internet_provider">
<label class="form-check-label fw-semibold" for="is_internet_provider">Internetleverandør</label>
<div class="small text-muted">Leverandøren kan vælges på internetforbindelser og får sin egen forbindelsesfane.</div>
</div></div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="vendor-form-section h-100">
<div class="vendor-form-section-title"><i class="bi bi-person-lines-fill"></i>Kontakt</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="email">E-mail</label>
<input type="email" class="form-control" id="email" autocomplete="email">
<div class="invalid-feedback">Angiv en gyldig e-mailadresse.</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Website</label> <label class="form-label" for="phone">Telefon</label>
<input type="url" class="form-control" id="website"> <input type="tel" class="form-control" id="phone" autocomplete="tel">
</div> </div>
<div class="col-md-6"> <div class="col-12"><label class="form-label" for="website">Website</label><input type="text" class="form-control" id="website" placeholder="https://example.com" autocomplete="url"></div>
<label class="form-label">Domain</label>
<input type="text" class="form-control" id="domain" placeholder="example.com">
</div> </div>
</div>
</div>
<div class="col-12"><div class="vendor-form-section">
<div class="vendor-form-section-title"><i class="bi bi-geo-alt"></i>Adresse og noter</div><div class="row g-3">
<div class="col-12"> <div class="col-12">
<label class="form-label">Adresse</label> <label class="form-label" for="address">Adresse</label>
<input type="text" class="form-control" id="address"> <input type="text" class="form-control" id="address" autocomplete="street-address">
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Postnummer</label> <label class="form-label">Postnummer</label>
@ -174,28 +234,20 @@
<label class="form-label">By</label> <label class="form-label">By</label>
<input type="text" class="form-control" id="city"> <input type="text" class="form-control" id="city">
</div> </div>
<div class="col-md-4"> <div class="col-md-4 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" id="is_active" checked><label class="form-check-label" for="is_active">Aktiv leverandør</label></div></div>
<label class="form-label">Kategori</label>
<select class="form-select" id="category">
<option value="general">General</option>
<option value="hardware">Hardware</option>
<option value="software">Software</option>
<option value="telecom">Telekom</option>
<option value="services">Services</option>
<option value="hosting">Hosting</option>
</select>
</div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Noter</label> <label class="form-label">Noter</label>
<textarea class="form-control" id="notes" rows="3"></textarea> <textarea class="form-control" id="notes" rows="3"></textarea>
</div> </div>
</div> </div>
</div></div>
</div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createVendor()"> <button type="submit" form="createVendorForm" class="btn btn-primary" id="createVendorSubmitBtn">
<i class="bi bi-check-lg me-2"></i>Opret Leverandør <span class="spinner-border spinner-border-sm me-2 d-none" id="createVendorSpinner"></span><i class="bi bi-check-lg me-2" id="createVendorIcon"></i>Opret leverandør
</button> </button>
</div> </div>
</div> </div>
@ -354,27 +406,109 @@ function nextPage() {
} }
function showCreateVendorModal() { function showCreateVendorModal() {
const form = document.getElementById('createVendorForm');
form.reset();
form.classList.remove('was-validated');
document.getElementById('is_active').checked = true;
setVendorCvrStatus('Indtast CVR og klik Hent for autofyld.');
setCreateVendorFeedback();
const modal = new bootstrap.Modal(document.getElementById('createVendorModal')); const modal = new bootstrap.Modal(document.getElementById('createVendorModal'));
modal.show(); modal.show();
} }
async function createVendor() { function setVendorCvrStatus(message, isError = false, isSuccess = false) {
const status = document.getElementById('vendorCvrLookupStatus');
status.textContent = message;
status.className = `form-text${isError ? ' text-danger' : ''}${isSuccess ? ' text-success' : ''}`;
}
function applyVendorCvrData(data) {
if (data.name) document.getElementById('name').value = data.name;
if (data.email) document.getElementById('email').value = data.email;
if (data.phone) document.getElementById('phone').value = data.phone;
if (data.address) document.getElementById('address').value = data.address;
if (data.postal_code || data.zipcode) document.getElementById('postal_code').value = data.postal_code || data.zipcode;
if (data.city) document.getElementById('city').value = data.city;
if (data.website) document.getElementById('website').value = data.website;
const domain = normalizeDomain(data.domain || data.website || (data.email?.split('@')[1] || ''));
if (domain) document.getElementById('domain').value = domain;
}
async function lookupVendorCvr() {
const input = document.getElementById('cvr_number');
const button = document.getElementById('vendorCvrLookupBtn');
const cvr = input.value.replace(/\D/g, '');
input.value = cvr;
if (cvr.length !== 8) {
input.classList.add('is-invalid');
setVendorCvrStatus('CVR skal være præcis 8 cifre.', true);
return;
}
input.classList.remove('is-invalid');
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>';
setVendorCvrStatus('Henter data fra FirmaAPI…');
try {
const response = await fetch(`/api/v1/cvr/${cvr}`);
if (!response.ok) {
if (response.status === 404) throw new Error('CVR blev ikke fundet.');
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || `Opslaget fejlede (HTTP ${response.status}).`);
}
applyVendorCvrData(await response.json());
setVendorCvrStatus('CVR-data hentet og felter autofyldt.', false, true);
} catch (error) {
setVendorCvrStatus(error.message || 'Kunne ikke hente CVR-data.', true);
} finally {
button.disabled = false;
button.textContent = 'Hent';
}
}
function setCreateVendorFeedback(message = '', type = 'danger') {
const feedback = document.getElementById('createVendorFeedback');
feedback.textContent = message;
feedback.className = message ? `alert alert-${type}` : 'alert d-none';
}
function normalizeDomain(value) {
return String(value || '').trim().toLowerCase()
.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].replace(/\.+$/, '');
}
async function createVendor(event) {
event?.preventDefault();
const form = document.getElementById('createVendorForm'); const form = document.getElementById('createVendorForm');
const cvrInput = document.getElementById('cvr_number');
cvrInput.value = cvrInput.value.replace(/\D/g, '');
form.classList.add('was-validated');
if (!form.checkValidity()) {
form.querySelector(':invalid')?.focus();
setCreateVendorFeedback('Kontrollér de markerede felter.');
return;
}
const websiteValue = document.getElementById('website').value.trim();
const submitButton = document.getElementById('createVendorSubmitBtn');
submitButton.disabled = true;
document.getElementById('createVendorSpinner').classList.remove('d-none');
document.getElementById('createVendorIcon').classList.add('d-none');
setCreateVendorFeedback('Opretter leverandøren…', 'info');
const vendor = { const vendor = {
name: document.getElementById('name').value, name: document.getElementById('name').value.trim(),
cvr_number: document.getElementById('cvr_number').value || null, cvr_number: cvrInput.value || null,
email: document.getElementById('email').value || null, email: document.getElementById('email').value.trim().toLowerCase() || null,
phone: document.getElementById('phone').value || null, phone: document.getElementById('phone').value.trim() || null,
website: document.getElementById('website').value || null, website: websiteValue ? (/^https?:\/\//i.test(websiteValue) ? websiteValue : `https://${websiteValue}`) : null,
domain: document.getElementById('domain').value || null, domain: normalizeDomain(document.getElementById('domain').value) || null,
address: document.getElementById('address').value || null, address: document.getElementById('address').value.trim() || null,
postal_code: document.getElementById('postal_code').value || null, postal_code: document.getElementById('postal_code').value.trim() || null,
city: document.getElementById('city').value || null, city: document.getElementById('city').value.trim() || null,
category: document.getElementById('category').value, category: document.getElementById('category').value,
priority: parseInt(document.getElementById('priority').value), notes: document.getElementById('notes').value.trim() || null,
notes: document.getElementById('notes').value || null, is_active: document.getElementById('is_active').checked,
is_active: true is_internet_provider: document.getElementById('is_internet_provider').checked
}; };
try { try {
@ -384,16 +518,16 @@ async function createVendor() {
body: JSON.stringify(vendor) body: JSON.stringify(vendor)
}); });
if (response.ok) { const payload = await response.json().catch(() => ({}));
bootstrap.Modal.getInstance(document.getElementById('createVendorModal')).hide(); if (!response.ok) throw new Error(payload.detail || 'Leverandøren kunne ikke oprettes.');
form.reset(); setCreateVendorFeedback('Leverandøren er oprettet. Åbner leverandørkortet…', 'success');
loadVendors(); window.setTimeout(() => { window.location.href = `/vendors/${payload.id}`; }, 350);
} else {
alert('Fejl ved oprettelse af leverandør');
}
} catch (error) { } catch (error) {
console.error('Error creating vendor:', error); console.error('Error creating vendor:', error);
alert('Kunne ikke oprette leverandør'); setCreateVendorFeedback(error.message || 'Kunne ikke oprette leverandør.');
submitButton.disabled = false;
document.getElementById('createVendorSpinner').classList.add('d-none');
document.getElementById('createVendorIcon').classList.remove('d-none');
} }
} }
@ -409,6 +543,17 @@ document.getElementById('searchInput').addEventListener('input', (e) => {
}); });
// Load on page ready // Load on page ready
document.addEventListener('DOMContentLoaded', loadVendors); document.addEventListener('DOMContentLoaded', () => {
document.getElementById('createVendorForm').addEventListener('submit', createVendor);
document.getElementById('cvr_number').addEventListener('input', (event) => {
event.target.value = event.target.value.replace(/\D/g, '').slice(0, 8);
event.target.classList.remove('is-invalid');
setVendorCvrStatus('Indtast CVR og klik Hent for autofyld.');
});
document.getElementById('cvr_number').addEventListener('keydown', (event) => {
if (event.key === 'Enter') { event.preventDefault(); lookupVendorCvr(); }
});
loadVendors();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,3 @@
ALTER TABLE user_sag_list_preferences
ADD COLUMN IF NOT EXISTS column_order JSONB NOT NULL DEFAULT '["id","company","contact","description","type","priority","status","owner","group","next_todo","created","start","deferred","deadline"]'::jsonb,
ADD COLUMN IF NOT EXISTS hidden_columns JSONB NOT NULL DEFAULT '[]'::jsonb;

View File

@ -0,0 +1,19 @@
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS is_manual_shared BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE internet_connections_connections parent
SET is_manual_shared = TRUE
WHERE parent.deleted_at IS NULL
AND parent.parent_id IS NULL
AND parent.allocation_model = 'shared'
AND parent.value_type = 'delefiber'
AND NOT EXISTS (
SELECT 1
FROM internet_connections_connections child
WHERE child.parent_id = parent.id
AND child.deleted_at IS NULL
AND (
child.value_type = 'subscription'
OR LOWER(COALESCE(child.value_label, '')) IN ('bmcnet', 'bmc networks')
)
);

View File

@ -0,0 +1,6 @@
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS sla_subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_internet_connections_sla_subscription
ON internet_connections_connections(sla_subscription_id)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,59 @@
ALTER TABLE vendors
ADD COLUMN IF NOT EXISTS is_internet_provider BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_vendors_internet_provider
ON vendors(is_internet_provider, is_active);
CREATE INDEX IF NOT EXISTS idx_internet_connections_vendor
ON internet_connections_connections(vendor_id)
WHERE deleted_at IS NULL;
-- Link existing provider text only when it identifies exactly one vendor.
UPDATE internet_connections_connections connection
SET vendor_id = candidate.vendor_id
FROM (
SELECT LOWER(BTRIM(connection.provider)) AS provider_key, MIN(vendor.id) AS vendor_id
FROM internet_connections_connections connection
JOIN vendors vendor ON LOWER(BTRIM(vendor.name)) = LOWER(BTRIM(connection.provider))
WHERE connection.deleted_at IS NULL AND NULLIF(BTRIM(connection.provider), '') IS NOT NULL
GROUP BY LOWER(BTRIM(connection.provider))
HAVING COUNT(DISTINCT vendor.id) = 1
) candidate
WHERE connection.deleted_at IS NULL
AND connection.vendor_id IS NULL
AND LOWER(BTRIM(connection.provider)) = candidate.provider_key;
CREATE OR REPLACE FUNCTION assign_internet_connection_vendor()
RETURNS TRIGGER AS $$
DECLARE
matched_vendor_id INTEGER;
BEGIN
IF NEW.vendor_id IS NULL AND NULLIF(BTRIM(NEW.provider), '') IS NOT NULL THEN
SELECT id INTO matched_vendor_id
FROM vendors
WHERE is_active = TRUE
AND is_internet_provider = TRUE
AND regexp_replace(
regexp_replace(LOWER(name), '(denmark|danmark|a/s|as)', '', 'g'),
'[^a-z0-9]', '', 'g'
) = regexp_replace(
regexp_replace(LOWER(NEW.provider), '(denmark|danmark|a/s|as)', '', 'g'),
'[^a-z0-9]', '', 'g'
)
ORDER BY id
LIMIT 1;
NEW.vendor_id := matched_vendor_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS internet_connections_assign_vendor ON internet_connections_connections;
CREATE TRIGGER internet_connections_assign_vendor
BEFORE INSERT OR UPDATE OF provider, vendor_id
ON internet_connections_connections
FOR EACH ROW
EXECUTE FUNCTION assign_internet_connection_vendor();

View File

@ -0,0 +1,73 @@
-- Production-ready case solutions and the first, read-only knowledge base release.
ALTER TABLE sag_solutions
ADD COLUMN IF NOT EXISTS problem TEXT,
ADD COLUMN IF NOT EXISTS root_cause TEXT,
ADD COLUMN IF NOT EXISTS investigation TEXT,
ADD COLUMN IF NOT EXISTS workaround TEXT,
ADD COLUMN IF NOT EXISTS visibility VARCHAR(30) NOT NULL DEFAULT 'internal',
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(30) NOT NULL DEFAULT 'draft',
ADD COLUMN IF NOT EXISTS is_final BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS products JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS updated_by_user_id INTEGER,
ADD COLUMN IF NOT EXISTS approved_by_user_id INTEGER,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
CREATE TABLE IF NOT EXISTS sag_solution_versions (
id BIGSERIAL PRIMARY KEY,
solution_id INTEGER NOT NULL REFERENCES sag_solutions(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
snapshot JSONB NOT NULL,
changed_by_user_id INTEGER,
change_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (solution_id, version_number)
);
CREATE INDEX IF NOT EXISTS idx_sag_solution_versions_solution
ON sag_solution_versions(solution_id, version_number DESC);
CREATE TABLE IF NOT EXISTS knowledge_articles (
id BIGSERIAL PRIMARY KEY,
solution_id INTEGER NOT NULL UNIQUE REFERENCES sag_solutions(id) ON DELETE RESTRICT,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
summary TEXT,
problem TEXT,
root_cause TEXT,
investigation TEXT,
solution TEXT NOT NULL,
workaround TEXT,
visibility VARCHAR(30) NOT NULL DEFAULT 'internal',
status VARCHAR(30) NOT NULL DEFAULT 'published',
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
products JSONB NOT NULL DEFAULT '[]'::jsonb,
version_number INTEGER NOT NULL DEFAULT 1,
published_by_user_id INTEGER,
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
search_document TSVECTOR GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(title, '') || ' ' || coalesce(summary, '') || ' ' ||
coalesce(problem, '') || ' ' || coalesce(root_cause, '') || ' ' ||
coalesce(investigation, '') || ' ' || coalesce(solution, '') || ' ' ||
coalesce(workaround, '') || ' ' || coalesce(tags::text, '') || ' ' ||
coalesce(products::text, '')
)
) STORED
);
CREATE INDEX IF NOT EXISTS idx_knowledge_articles_search
ON knowledge_articles USING GIN(search_document);
CREATE INDEX IF NOT EXISTS idx_knowledge_articles_scope
ON knowledge_articles(status, visibility, customer_id, updated_at DESC);
-- Preserve the two existing solutions as drafts; publication always requires an explicit approval.
UPDATE sag_solutions
SET approval_status = COALESCE(NULLIF(approval_status, ''), 'draft'),
visibility = COALESCE(NULLIF(visibility, ''), 'internal')
WHERE approval_status IS NULL OR approval_status = '' OR visibility IS NULL OR visibility = '';

View File

@ -0,0 +1,34 @@
## Plan: Website Content Administration
Implementere et nyt `website_content`-modul i HUB, der skriver direkte til den eksterne MySQL-database `bmcnetworks_26`.
**Hoveddele**
1. Tilføje MySQL-konfiguration, connection pool og transaktionshåndtering.
2. Oprette CRUD for:
- Kundereferencer
- Aktuel driftsstatus
- Driftshistorik
3. Implementere “Afslut hændelse og flyt til historik” som én MySQL-transaktion.
4. Bruge soft-hide via `is_active`/`is_public`; ingen DELETE-operationer.
5. Tilføje adgangskontrol med egne rettigheder som `website_content.view` og `website_content.edit`.
6. Tilføje responsiv Nordic Top-administrationsside med:
- Logo-upload og preview
- Rækkefølge
- Status-severity
- Planlagte start/sluttider
- Historik
- Dark mode-kompatibilitet
7. Registrere API, frontend-route og navigation i HUB.
8. Tilføje fokuserede tests med mocket MySQL.
**Logo-upload**
Den eksisterende `logo_url VARCHAR(500)` kan ikke indeholde almindelige billedfiler. Derfor skal website-databasen udvides med eksempelvis `logo_blob` og `logo_mime_type`, og website-projektets `content.php` skal suppleres med en billedendpoint.
HUB-delen kan implementeres i dette workspace. Website-ændringerne kræver adgang til det separate projekt:
- `/Users/christianthomas/DEV/new bmcnetworks/api/content.php`
- `/Users/christianthomas/DEV/new bmcnetworks/sql/pending/001_schema.sql`
Planen er gemt i sessionen. Godkend planen, så går jeg videre med implementationen.

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Idempotent correction of verified circuit, address, speed and IP reference data."""
from __future__ import annotations
import ipaddress
from app.core.database import execute_insert, execute_query_single, execute_update, init_db
CONNECTIONS = [
("NKA-021426", "Tobaksvej 25, 2860 Søborg", 100, ["83.151.156.52/30", "77.233.233.208/28"]),
("NKA-023762", "Lundbygaardsvej 100, 4750 Lundby", 200, ["77.233.233.80/28", "152.115.111.240/30"]),
("NKA-020900", "Rydagervej 27, 2620 Albertslund", 1000, ["87.116.1.200/29"]),
("NKA-023036", "Slotsmarken 18, 2970 Hørsholm", 500, ["152.115.140.8/30", "217.195.179.0/26"]),
("NKA-022948", "Oldenburg Alle 7, 2630 Taastrup", 200, ["130.185.140.120/30", "62.116.202.0/28"]),
("NKA-022949", "Borupvang 2B, 2750 Ballerup", 500, ["152.115.70.140/30", "87.116.23.0/28"]),
("NKA-023763", "Slotsmarken 10, 2970 Hørsholm", 200, ["152.115.137.60/30", "62.116.202.96/28"]),
("NKA-024219", "Mileparken 22, 2740 Skovlunde", 1000, ["152.115.111.232/30", "152.115.63.128/26"]),
("NML-024495", "Lundbygaardsvej 100, 4750 Lundby", None, ["152.115.111.236/30"]),
("NKA-027783", "Ejby Industrivej 1, 2600 Glostrup", 1000, []),
("NKA-027784", "Møgelbakken 8, 8520 Lystrup", 1000, ["130.185.141.92/30"]),
("NKA-028639", "Slotsmarken 11, 2970 Hørsholm", 100, ["152.115.36.80/28"]),
("NKA-025021", "Broenge 4, 2635 Ishøj", 300, ["152.115.107.168/30", "130.185.134.44/30"]),
("NKA-031137", "Marielundvej 30, 2730 Herlev", 500, ["217.74.209.220/30"]),
("NKA-031083", "Slotsmarken 17, 2970 Hørsholm", 100, ["93.176.69.96/30"]),
("NKA-031131", "Sankt Kunds vej 26, 1903 Frederiksberg C", 500, ["152.115.178.192/30"]),
("NKA-021047", "Herstedvang 14, 2620 Albertslund", 300, ["83.136.94.128/26"]),
("NKA-027964", "Firskovvej 36, 2800 Kongens Lyngby", 1000, ["217.74.219.56/30", "152.115.61.32/27"]),
("HB944140", "Lejrvej 17-19, 3500 Værløse", 5000, []),
("NKA-021275", "Ejby Industrivej 1, 2600 Glostrup", 1000, ["87.116.30.96/27", "5.56.159.32/29"]),
]
def reference_key(value: str) -> str:
return "".join(character for character in value.upper() if character.isalnum())
def reconcile() -> dict[str, int]:
result = {"created_connections": 0, "updated_connections": 0, "created_ranges": 0, "updated_ranges": 0}
for reference, address, speed, cidrs in CONNECTIONS:
connection = execute_query_single(
"""SELECT id FROM internet_connections_connections
WHERE deleted_at IS NULL
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
ORDER BY id DESC LIMIT 1""",
(reference_key(reference),),
)
if connection:
connection_id = int(connection["id"])
execute_update(
"""UPDATE internet_connections_connections
SET circuit_number=%s, address=%s,
speed_mbps=COALESCE(%s, speed_mbps),
download_mbps=COALESCE(%s, download_mbps),
upload_mbps=COALESCE(%s, upload_mbps), updated_at=CURRENT_TIMESTAMP
WHERE id=%s""",
(reference, address, speed, speed, speed, connection_id),
)
result["updated_connections"] += 1
else:
connection_id = execute_insert(
"""INSERT INTO internet_connections_connections
(name,provider,address,status,circuit_number,speed_mbps,download_mbps,upload_mbps,
monthly_cost,sales_price,allocation_model,value_type,notes)
VALUES (%s,'GlobalConnect A/S',%s,'pending',%s,%s,%s,%s,0,0,'dedicated','other',
'Oprettet fra verificeret kredsløbsoversigt. Kunde tildeles manuelt.') RETURNING id""",
(address, address, reference, speed, speed, speed),
)
result["created_connections"] += 1
for raw_cidr in cidrs:
cidr = str(ipaddress.ip_network(raw_cidr.replace(" ", ""), strict=False))
ip_range = execute_query_single(
"""SELECT id FROM internet_connections_ip_ranges
WHERE deleted_at IS NULL AND cidr::cidr=%s::cidr ORDER BY id LIMIT 1""",
(cidr,),
)
if ip_range:
execute_update(
"""UPDATE internet_connections_ip_ranges
SET connection_id=%s, provider_reference=%s, service_address=%s,
updated_at=CURRENT_TIMESTAMP WHERE id=%s""",
(connection_id, reference, address, ip_range["id"]),
)
result["updated_ranges"] += 1
else:
execute_insert(
"""INSERT INTO internet_connections_ip_ranges
(connection_id,name,cidr,description,provider_reference,service_address,monthly_cost,sales_price)
VALUES (%s,%s,%s,'Oprettet fra verificeret kredsløbsoversigt.',%s,%s,0,0) RETURNING id""",
(connection_id, f"IPv4 · {cidr}", cidr, reference, address),
)
result["created_ranges"] += 1
return result
if __name__ == "__main__":
init_db()
print(reconcile())

View File

@ -31,6 +31,79 @@ def latest_globalconnect_extractions() -> list[dict]:
return [dict(row) for row in rows] return [dict(row) for row in rows]
def snapshot_manual_allocations() -> list[dict]:
"""Keep deliberate CRM ownership across a destructive invoice rebuild.
Supplier imports must never infer a customer. A clean rebuild previously
also discarded customers that a user had already selected, which allowed a
later, shifted invoice address to become the new canonical address.
"""
rows = execute_query(
"""
SELECT DISTINCT ON (
regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g')
)
regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') AS reference_key,
circuit_number,
customer_id,
address
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND customer_id IS NOT NULL
AND BTRIM(COALESCE(circuit_number, '')) <> ''
ORDER BY
regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g'),
updated_at DESC,
id DESC
"""
) or []
return [dict(row) for row in rows]
def restore_manual_allocations(allocations: list[dict]) -> int:
restored = 0
for allocation in allocations:
reference_key = str(allocation.get("reference_key") or "").strip()
if not reference_key:
continue
connection = execute_query_single(
"""
SELECT id
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
ORDER BY id DESC
LIMIT 1
""",
(reference_key,),
)
if not connection:
continue
connection_id = int(connection["id"])
execute_update(
"""
UPDATE internet_connections_connections
SET customer_id = %s,
address = COALESCE(NULLIF(BTRIM(%s), ''), address),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(allocation.get("customer_id"), allocation.get("address"), connection_id),
)
if str(allocation.get("address") or "").strip():
execute_update(
"""
UPDATE internet_connections_ip_ranges
SET service_address = %s,
updated_at = CURRENT_TIMESTAMP
WHERE connection_id = %s AND deleted_at IS NULL
""",
(allocation["address"], connection_id),
)
restored += 1
return restored
def reset_all_internet_data() -> dict: def reset_all_internet_data() -> dict:
summary: dict[str, int] = {} summary: dict[str, int] = {}
counts = execute_query_single( counts = execute_query_single(
@ -94,7 +167,7 @@ def reset_all_internet_data() -> dict:
return summary return summary
def rebuild_from_globalconnect() -> dict: def rebuild_from_globalconnect(manual_allocations: list[dict] | None = None) -> dict:
extractions = latest_globalconnect_extractions() extractions = latest_globalconnect_extractions()
results = [] results = []
totals = defaultdict(int) totals = defaultdict(int)
@ -110,7 +183,9 @@ def rebuild_from_globalconnect() -> dict:
) )
if not extraction: if not extraction:
continue continue
result = _sync_globalconnect_extraction_to_internet(dict(extraction)) # A reset intentionally rebuilds previously processed invoices, so the
# normal idempotency guard must not skip their historical sync runs.
result = _sync_globalconnect_extraction_to_internet(dict(extraction), force=True)
result_summary = { result_summary = {
"file_id": extraction_stub["file_id"], "file_id": extraction_stub["file_id"],
"extraction_id": extraction_stub["extraction_id"], "extraction_id": extraction_stub["extraction_id"],
@ -133,6 +208,7 @@ def rebuild_from_globalconnect() -> dict:
totals["skipped_connection_lines"] += result_summary["skipped_connection_lines"] totals["skipped_connection_lines"] += result_summary["skipped_connection_lines"]
totals["skipped_ip_range_lines"] += result_summary["skipped_ip_range_lines"] totals["skipped_ip_range_lines"] += result_summary["skipped_ip_range_lines"]
restored_allocations = restore_manual_allocations(manual_allocations or [])
counts = execute_query_single( counts = execute_query_single(
""" """
SELECT SELECT
@ -151,6 +227,7 @@ def rebuild_from_globalconnect() -> dict:
"active_ip_ranges": int(counts.get("active_ip_ranges") or 0), "active_ip_ranges": int(counts.get("active_ip_ranges") or 0),
"active_ip_addresses": int(counts.get("active_ip_addresses") or 0), "active_ip_addresses": int(counts.get("active_ip_addresses") or 0),
}, },
"restored_manual_allocations": restored_allocations,
} }
@ -160,9 +237,10 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
init_db() init_db()
manual_allocations = snapshot_manual_allocations()
payload = { payload = {
"reset": reset_all_internet_data(), "reset": reset_all_internet_data(),
"rebuild": None if args.skip_rebuild else rebuild_from_globalconnect(), "rebuild": None if args.skip_rebuild else rebuild_from_globalconnect(manual_allocations),
} }
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str)) print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0 return 0

View File

@ -1180,14 +1180,23 @@
const timer = ((latestSections || {}).timer || {}).active || {}; const timer = ((latestSections || {}).timer || {}).active || {};
const ownTimers = ((latestSections || {}).timer || {}).own || {}; const ownTimers = ((latestSections || {}).timer || {}).own || {};
const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0; const pausedTimers = Array.isArray(ownTimers.paused) ? ownTimers.paused : [];
const pausedTimer = pausedTimers[0] || null;
const hasPausedTimer = !!pausedTimer;
const hasActiveTimer = !!timer.active; const hasActiveTimer = !!timer.active;
if (timerChip && timerText) { if (timerChip && timerText) {
timerChip.classList.toggle('is-hidden', !hasActiveTimer); timerChip.classList.toggle('is-hidden', !hasActiveTimer && !hasPausedTimer);
timerChip.classList.toggle('is-paused', !hasActiveTimer && hasPausedTimer);
if (hasActiveTimer) { if (hasActiveTimer) {
const elapsed = timer.elapsed_hhmmss || '00:00:00'; const elapsed = timer.elapsed_hhmmss || '00:00:00';
const name = timer.sag_navn || ('Sag #' + (timer.sag_id || '')); const name = timer.sag_navn || ('Sag #' + (timer.sag_id || ''));
timerText.textContent = name + ' - ' + elapsed; timerText.textContent = name + ' - ' + elapsed;
timerChip.title = 'Aktiv timer på ' + name;
} else if (hasPausedTimer) {
const name = pausedTimer.sag_navn || ('Sag #' + (pausedTimer.sag_id || ''));
const elapsed = pausedTimer.elapsed_hhmmss || '00:00:00';
timerText.textContent = 'Pauset · ' + name + ' · ' + elapsed;
timerChip.title = 'Pauset timer på ' + name + ' klik for at åbne sagen';
} }
} }
@ -1198,7 +1207,8 @@
} }
if (pauseBtn) { if (pauseBtn) {
pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer; pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer;
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag senest pausede timer' : 'Ingen timer at pause'); const pausedName = hasPausedTimer ? (pausedTimer.sag_navn || ('Sag #' + (pausedTimer.sag_id || ''))) : '';
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag ' + pausedName : 'Ingen timer at pause');
pauseBtn.innerHTML = hasActiveTimer ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>'; pauseBtn.innerHTML = hasActiveTimer ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>';
} }
if (stopBtn) { if (stopBtn) {
@ -1905,6 +1915,16 @@
return stopTimer(active.time_entry_id); return stopTimer(active.time_entry_id);
} }
function notifyTimerStateChanged(action, payload) {
const detail = Object.assign({ action: action, changed_at: new Date().toISOString() }, payload || {});
window.dispatchEvent(new CustomEvent('bb:timer-state-changed', { detail: detail }));
try {
window.localStorage.setItem('bmc:timer-state-changed', JSON.stringify(detail));
} catch (error) {
console.debug('Could not broadcast timer state', error);
}
}
function pauseActiveTimer() { function pauseActiveTimer() {
return fetch('/api/v1/timetracking/time/pause', { return fetch('/api/v1/timetracking/time/pause', {
method: 'POST', method: 'POST',
@ -1913,7 +1933,7 @@
body: '{}' body: '{}'
}).then(function (res) { }).then(function (res) {
if (!res.ok) { if (!res.ok) {
throw new Error('Kunne ikke pause timer'); return readApiError(res, 'Kunne ikke pause timer.').then(function (message) { throw new Error(message); });
} }
return res.json().catch(function () { return {}; }); return res.json().catch(function () { return {}; });
}); });
@ -1932,7 +1952,7 @@
body: JSON.stringify(payload) body: JSON.stringify(payload)
}).then(function (res) { }).then(function (res) {
if (!res.ok) { if (!res.ok) {
throw new Error('Kunne ikke genoptage timer'); return readApiError(res, 'Kunne ikke genoptage timer.').then(function (message) { throw new Error(message); });
} }
return res.json().catch(function () { return {}; }); return res.json().catch(function () { return {}; });
}); });
@ -2777,21 +2797,43 @@
const paused = Array.isArray(own.paused) ? own.paused : []; const paused = Array.isArray(own.paused) ? own.paused : [];
if (activeTimer.active) { if (activeTimer.active) {
pauseBtn.disabled = true;
pauseActiveTimer() pauseActiveTimer()
.then(fetchBottomBarState) .then(fetchBottomBarState)
.then(applyState) .then(applyState)
.then(function () {
notifyTimerStateChanged('paused');
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-pause-circle me-1 text-success"></i>Timer sat på pause.';
})
.catch(function (err) { .catch(function (err) {
console.warn('Failed pausing timer', err); console.warn('Failed pausing timer', err);
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke pause timer.');
})
.finally(function () {
updateActivityZone();
}); });
return; return;
} }
const pausedTimeId = Number((((paused[0] || {}).time_entry_id) || ((paused[0] || {}).id) || 0)); const pausedTimeId = Number((((paused[0] || {}).time_entry_id) || ((paused[0] || {}).id) || 0));
pauseBtn.disabled = true;
resumeTimer(pausedTimeId || null) resumeTimer(pausedTimeId || null)
.then(fetchBottomBarState) .then(fetchBottomBarState)
.then(applyState) .then(applyState)
.then(function () {
notifyTimerStateChanged('resumed', { time_id: pausedTimeId || null });
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-play-circle me-1 text-success"></i>Timer genoptaget.';
})
.catch(function (err) { .catch(function (err) {
console.warn('Failed resuming timer', err); console.warn('Failed resuming timer', err);
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke genoptage timer.');
})
.finally(function () {
updateActivityZone();
}); });
}); });
} }
@ -2801,6 +2843,7 @@
stopActiveTimer() stopActiveTimer()
.then(fetchBottomBarState) .then(fetchBottomBarState)
.then(applyState) .then(applyState)
.then(function () { notifyTimerStateChanged('stopped'); })
.catch(function (err) { .catch(function (err) {
console.warn('Failed stopping timer', err); console.warn('Failed stopping timer', err);
}); });
@ -2816,7 +2859,10 @@
if (timerChip) { if (timerChip) {
timerChip.addEventListener('click', function () { timerChip.addEventListener('click', function () {
const timer = (((latestSections || {}).timer || {}).active || {}); const timer = (((latestSections || {}).timer || {}).active || {});
const sagId = Number(timer.sag_id || 0); const own = (((latestSections || {}).timer || {}).own || {});
const paused = Array.isArray(own.paused) ? own.paused : [];
const visibleTimer = timer.active ? timer : (paused[0] || {});
const sagId = Number(visibleTimer.sag_id || 0);
window.location.href = sagId > 0 ? ('/sag/' + sagId + '/v3') : '/timetracking'; window.location.href = sagId > 0 ? ('/sag/' + sagId + '/v3') : '/timetracking';
}); });
} }
@ -3098,6 +3144,7 @@
if (switchAction === 'pause-now') { if (switchAction === 'pause-now') {
pauseActiveTimer().then(function () { pauseActiveTimer().then(function () {
notifyTimerStateChanged('paused');
switchCaseState.decision = 'pause'; switchCaseState.decision = 'pause';
switchCaseState.activeTimer = null; switchCaseState.activeTimer = null;
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timer sat på pause. Du kan nu starte ny timer.'); switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timer sat på pause. Du kan nu starte ny timer.');
@ -3110,6 +3157,7 @@
if (switchAction === 'stop-now') { if (switchAction === 'stop-now') {
stopActiveTimer().then(function () { stopActiveTimer().then(function () {
notifyTimerStateChanged('stopped');
switchCaseState.decision = 'stop'; switchCaseState.decision = 'stop';
switchCaseState.activeTimer = null; switchCaseState.activeTimer = null;
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.'); switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.');

View File

@ -99,12 +99,14 @@ def test_sync_globalconnect_extraction_creates_connections_and_ip_ranges(monkeyp
assert created_ranges assert created_ranges
assert ensured_ranges assert ensured_ranges
assert created_connections[0][0] == "Malerfirmaet Gert Jensen ApS" assert created_connections[0][0] == "Malerfirmaet Gert Jensen ApS"
assert created_connections[0][2] is None
assert created_connections[0][9] == 100 assert created_connections[0][9] == 100
assert created_connections[0][10] == 100 assert created_connections[0][10] == 100
assert created_connections[0][11] == 100 assert created_connections[0][11] == 100
assert created_connections[0][13] == "dedicated" assert created_connections[0][13] == "dedicated"
assert created_connections[0][14] == "other" assert created_connections[0][14] == "other"
assert created_ranges[0][2] == "152.115.84.232/29" assert created_ranges[0][2] == "152.115.84.232/29"
assert created_ranges[0][6] is None
def test_sync_globalconnect_infers_dsl_kbps_speed(monkeypatch): def test_sync_globalconnect_infers_dsl_kbps_speed(monkeypatch):
@ -187,6 +189,7 @@ def test_sync_globalconnect_marks_uncertain_connection_pending(monkeypatch):
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines) monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: []) monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: []) monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
monkeypatch.setattr(supplier_module, "execute_query_single", lambda query, params=None: None)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction) result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
@ -219,7 +222,7 @@ def test_upsert_globalconnect_connection_requires_service_address(monkeypatch):
assert connection_id is None assert connection_id is None
def test_sync_globalconnect_assigns_shared_bmc_value_model(monkeypatch): def test_sync_globalconnect_creates_pending_reference_shell_without_customer(monkeypatch):
created_connections = [] created_connections = []
extraction = { extraction = {
@ -264,13 +267,14 @@ def test_sync_globalconnect_assigns_shared_bmc_value_model(monkeypatch):
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction) result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 0 assert result["connections_synced"] == 1
assert result["ip_ranges_synced"] == 0 assert result["ip_ranges_synced"] == 1
assert result["skipped_orphan_ip_ranges"] == 1 assert result["skipped_orphan_ip_ranges"] == 0
assert created_connections == [] assert created_connections
assert created_connections[0][2] is None
def test_upsert_globalconnect_connection_assigns_delefiber_for_internal_shared_owner(monkeypatch): def test_upsert_globalconnect_connection_does_not_infer_shared_from_address_or_mpls(monkeypatch):
created_connections = [] created_connections = []
line = { line = {
"description": "1 Gbps fiberforbindelse MPLS VPN", "description": "1 Gbps fiberforbindelse MPLS VPN",
@ -306,9 +310,68 @@ def test_upsert_globalconnect_connection_assigns_delefiber_for_internal_shared_o
assert connection_id == 901 assert connection_id == 901
assert created_connections assert created_connections
assert created_connections[0][2] == 1662 assert created_connections[0][2] is None
assert created_connections[0][-3] == "shared" assert created_connections[0][-3] == "dedicated"
assert created_connections[0][-2] == "delefiber" assert created_connections[0][-2] == "other"
def test_shared_classification_requires_explicit_invoice_wording():
assert supplier_module._should_assign_internal_bmc_owner(
[{"description": "Delt transit backbone"}], None, "Testvej 1"
) is True
assert supplier_module._should_assign_internal_bmc_owner(
[{"description": "MPLS VPN 1 Gbps"}], None, "Testvej 1"
) is False
def test_existing_ip_range_matches_canonical_network_not_raw_text(monkeypatch):
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [{
"connection_id": 44,
"cidr": "192.0.2.0/29",
"service_address": "Testvej 1, 8000 Aarhus",
"provider_reference": "NKA123456",
"connection_address": "Testvej 1, 8000 Aarhus",
"connection_reference": "NKA123456",
}],
)
result = supplier_module._resolve_existing_ip_range_connection({
"ip_address": "192.0.2.1 / 29",
"provider_reference": "NKA-123456",
"service_address": "Testvej 1, 8000 Aarhus",
})
assert result["connection_id"] == 44
assert result["conflict_reason"] is None
def test_unique_circuit_reference_matches_dash_and_dsl_eb_alias(monkeypatch):
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [
{"id": 11, "circuit_number": "NKA020900"},
{"id": 12, "circuit_number": "DSL-EB528263"},
],
)
assert supplier_module._find_unique_globalconnect_connection_by_reference("NKA-020900") == 11
assert supplier_module._find_unique_globalconnect_connection_by_reference("EB528263") == 12
def test_reference_match_keys_treat_eb_and_dsl_eb_as_same_circuit():
assert supplier_module._provider_reference_match_keys("EB528263") == {"EB528263", "DSLEB528263"}
assert supplier_module._provider_reference_match_keys("DSL-EB528263") == {"EB528263", "DSLEB528263"}
def test_bare_eb_ip_reference_is_not_allowed_to_create_a_pending_connection():
source = Path('app/billing/backend/supplier_invoices.py').read_text()
assert 'not simulate and not reference.startswith("EB")' in source
assert 'simulate and not reference.startswith("EB")' in source
def test_sync_globalconnect_attaches_ip_range_to_existing_connection(monkeypatch): def test_sync_globalconnect_attaches_ip_range_to_existing_connection(monkeypatch):
@ -511,6 +574,7 @@ def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
"execute_query", "execute_query",
lambda query, params=None: [{"id": 16, "address": "Andenvej 99, 2100 København Ø"}] if "FROM internet_connections_connections" in query else [], lambda query, params=None: [{"id": 16, "address": "Andenvej 99, 2100 København Ø"}] if "FROM internet_connections_connections" in query else [],
) )
monkeypatch.setattr(supplier_module, "execute_query_single", lambda query, params=None: None)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction) result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
@ -519,7 +583,7 @@ def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
assert "anden adresse" in result["skipped_items"][0]["reason"].lower() assert "anden adresse" in result["skipped_items"][0]["reason"].lower()
def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_service_address(monkeypatch): def test_sync_globalconnect_uses_circuit_address_when_ip_line_address_is_shifted(monkeypatch):
extraction = { extraction = {
"extraction_id": 180, "extraction_id": 180,
"vendor_name": "GlobalConnect A/S", "vendor_name": "GlobalConnect A/S",
@ -585,7 +649,7 @@ def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_s
created_connections.append(params) created_connections.append(params)
return 96 return 96
if "INSERT INTO internet_connections_ip_ranges" in query: if "INSERT INTO internet_connections_ip_ranges" in query:
raise AssertionError("IP-range should not be created when service address conflicts") return 97
return 1 return 1
monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query) monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query)
@ -596,13 +660,14 @@ def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_s
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction) result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 1 assert result["connections_synced"] == 1
assert result["ip_ranges_synced"] == 0 assert result["ip_ranges_synced"] == 1
assert result["skipped_orphan_ip_ranges"] == 1 assert result["skipped_orphan_ip_ranges"] == 0
assert created_connections assert created_connections
skipped_ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"] ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"]
assert skipped_ip_entries assert ip_entries[0]["status"] == "synced"
assert skipped_ip_entries[0]["status"] == "skipped" assert ip_entries[0]["matched_by"] == "unique_circuit_reference"
assert "ingen forbindelse fundet" in (skipped_ip_entries[0]["reason"] or "").lower() assert ip_entries[0]["service_address_corrected"] is True
assert "Rydagervej 27" in ip_entries[0]["address_warning"]
def test_upsert_globalconnect_connection_logs_changes_and_creates_case(monkeypatch): def test_upsert_globalconnect_connection_logs_changes_and_creates_case(monkeypatch):

View File

@ -1,5 +1,7 @@
import sys import sys
import asyncio import asyncio
import io
import zipfile
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@ -9,6 +11,33 @@ from fastapi.testclient import TestClient
from main import app from main import app
def _build_ip_nordic_test_xlsx():
rows = [
["Company", "Name", "Startdate", "Salgspris", "Kostpris", "InstallationAddress"],
["99773", "BMC Denmark ApS", "43418", "2495", "1386", "Engholm Parkvej 8, 3450 Allerød"],
["99773", "BMC Denmark ApS", "43418", "129", "88", "Engholm Parkvej 8, 3450 Allerød "],
]
xml_rows = []
for row_number, values in enumerate(rows, start=1):
cells = []
for column_number, value in enumerate(values):
column = chr(ord('A') + column_number)
if row_number == 1 or column in {'A', 'B', 'F'}:
cells.append(f'<c r="{column}{row_number}" t="inlineStr"><is><t>{value}</t></is></c>')
else:
cells.append(f'<c r="{column}{row_number}"><v>{value}</v></c>')
xml_rows.append(f'<row r="{row_number}">{"".join(cells)}</row>')
sheet = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<sheetData>{"".join(xml_rows)}</sheetData></worksheet>'
)
output = io.BytesIO()
with zipfile.ZipFile(output, 'w') as archive:
archive.writestr('xl/worksheets/sheet1.xml', sheet)
return output.getvalue()
def test_internet_connections_module_routes_are_available(): def test_internet_connections_module_routes_are_available():
client = TestClient(app) client = TestClient(app)
@ -22,7 +51,6 @@ def test_internet_connections_module_routes_are_available():
'Internetforbindelser' in page_response.text 'Internetforbindelser' in page_response.text
or "window.location.href = '/login'" in page_response.text or "window.location.href = '/login'" in page_response.text
) )
detail_response = client.get('/economy/internet-connections/1') detail_response = client.get('/economy/internet-connections/1')
assert detail_response.status_code == 200 assert detail_response.status_code == 200
assert ( assert (
@ -31,6 +59,42 @@ def test_internet_connections_module_routes_are_available():
) )
def test_ip_nordic_xlsx_parser_groups_lines_by_company_and_address():
from app.modules.internet_connections.backend import router as internet_router
items = internet_router._parse_ip_nordic_xlsx(_build_ip_nordic_test_xlsx())
assert len(items) == 1
assert items[0]['line_count'] == 2
assert float(items[0]['monthly_cost']) == 1474
assert float(items[0]['sales_price']) == 2624
assert items[0]['address'] == 'Engholm Parkvej 8, 3450 Allerød'
def test_ip_nordic_import_preview_never_assigns_customer(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: None)
client = TestClient(app)
response = client.post(
'/api/v1/internet-connections/import/ip-nordic',
files={'file': ('IP_Nordic.xlsx', _build_ip_nordic_test_xlsx(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
data={'commit': 'false'},
)
assert response.status_code == 200
assert response.json()['create_count'] == 1
assert response.json()['customer_auto_assignment'] is False
def test_internet_connections_page_has_ip_nordic_preview_import():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert 'Importér IP Nordic' in template
assert 'previewIpNordicImport()' in template
assert 'commitIpNordicImport()' in template
def test_create_ip_range_rejects_invalid_cidr(monkeypatch): def test_create_ip_range_rejects_invalid_cidr(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router from app.modules.internet_connections.backend import router as internet_router
@ -50,6 +114,17 @@ def test_create_ip_range_rejects_invalid_cidr(monkeypatch):
assert 'CIDR' in response.json()['detail'] assert 'CIDR' in response.json()['detail']
def test_document_entity_extraction_canonicalizes_cidr_with_host_bits_and_spaces():
from app.modules.internet_connections.backend import router as internet_router
entities = internet_router._extract_segment_entities(
"WAN range 192.0.2.1 / 29 og gateway 192.0.2.2"
)
assert entities["cidr_blocks"] == ["192.0.2.0/29"]
assert entities["ip_addresses"] == ["192.0.2.2"]
def test_create_connection_requires_address(monkeypatch): def test_create_connection_requires_address(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router from app.modules.internet_connections.backend import router as internet_router
@ -559,7 +634,7 @@ def test_contract_overview_returns_empty_list_when_query_fails(monkeypatch):
assert response.json() == [] assert response.json() == []
def test_list_connections_returns_empty_list_when_query_fails(monkeypatch): def test_list_connections_returns_visible_error_when_query_fails(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router from app.modules.internet_connections.backend import router as internet_router
def fail_execute_query(query, params=None): def fail_execute_query(query, params=None):
@ -570,8 +645,8 @@ def test_list_connections_returns_empty_list_when_query_fails(monkeypatch):
client = TestClient(app) client = TestClient(app)
response = client.get('/api/v1/internet-connections') response = client.get('/api/v1/internet-connections')
assert response.status_code == 200 assert response.status_code == 500
assert response.json() == [] assert response.json()['detail'] == 'Kunne ikke hente internetforbindelser'
def test_pricing_summary_returns_zeroes_when_query_fails(monkeypatch): def test_pricing_summary_returns_zeroes_when_query_fails(monkeypatch):
@ -683,6 +758,210 @@ def test_list_connections_supports_shared_only_filter(monkeypatch):
assert payload['is_shared_head'] is True assert payload['is_shared_head'] is True
def test_first_bmcnet_child_promotes_head_and_preserves_customer(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
single_results = iter([
{'id': 12, 'allocation_model': 'dedicated', 'value_type': 'other', 'value_label': 'Internetforbindelse'},
{'child_count': 1},
])
writes = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: next(single_results))
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: writes.append((query, params)) or [])
assert internet_router._sync_bmcnet_parent_classification(12) is True
classification_query, params = writes[0]
assert 'customer_id' not in classification_query
assert params == ('shared', 'delefiber', None, 12)
def test_last_bmcnet_child_removal_returns_head_to_dedicated(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
single_results = iter([
{'id': 12, 'allocation_model': 'shared', 'value_type': 'delefiber', 'value_label': None},
{'child_count': 0},
])
writes = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: next(single_results))
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: writes.append((query, params)) or [])
assert internet_router._sync_bmcnet_parent_classification(12) is True
assert writes[0][1] == ('dedicated', 'other', 'Internetforbindelse', 12)
def test_manually_marked_delefiber_stays_shared_without_children(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
single_results = iter([
{'id': 12, 'allocation_model': 'shared', 'value_type': 'delefiber', 'value_label': None, 'is_manual_shared': True},
{'child_count': 0},
])
writes = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: next(single_results))
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: writes.append((query, params)) or [])
assert internet_router._sync_bmcnet_parent_classification(12) is False
assert writes == []
def test_bmcnet_wizard_is_available_on_dedicated_root_connection():
template = Path('app/modules/internet_connections/templates/detail.html').read_text()
assert "const canCreateBmcnet = Boolean(connection && !connection.parent_id);" in template
assert "filter((item) => !item?.parent_id)" in template
def test_list_connections_supports_unallocated_filter(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
assert "ic.customer_id IS NULL" in query
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
response = TestClient(app).get('/api/v1/internet-connections', params={'unallocated_only': 'true'})
assert response.status_code == 200
assert response.json() == []
def test_internet_connection_tabs_include_dedicated_and_unallocated():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert "setActiveTab('dedicated')" in template
assert "setActiveTab('unallocated')" in template
assert "params.set('allocation_model', 'dedicated')" in template
assert "params.set('allocated_only', 'true')" in template
assert "params.set('unallocated_only', 'true')" in template
def test_processed_internet_invoices_have_their_own_tab():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert "setActiveTab('invoices')" in template
assert 'id="invoiceProcessingOverview"' in template
assert "document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode)" in template
assert "document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode)" in template
assert "if (activeTab === 'invoices') return loadInvoiceSyncRuns();" in template
def test_connections_show_and_allocate_sla_subscriptions():
index_template = Path('app/modules/internet_connections/templates/index.html').read_text()
detail_template = Path('app/modules/internet_connections/templates/detail.html').read_text()
migration = Path('migrations/1025_internet_connections_sla_subscription.sql').read_text()
assert 'sla_subscription_id' in migration
assert 'Ingen SLA-aftale' in index_template
assert 'id="slaSubscriptionSelect"' in detail_template
assert 'Prisen skal kontrolleres' in detail_template
assert "JSON.stringify({ sla_subscription_id: value })" in detail_template
def test_manual_bmc_shared_fiber_does_not_suggest_customer_allocation():
template = Path('app/modules/internet_connections/templates/detail.html').read_text()
assert 'const isBmcSharedFiber = Boolean(' in template
assert 'connection.is_manual_shared' in template
assert 'const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber;' in template
def test_invoice_review_reconciles_ranges_that_are_already_allocated():
template = Path('app/modules/internet_connections/templates/index.html').read_text()
from app.modules.internet_connections.backend import router as internet_router
assert "invoice-sync-runs/reconcile', { method: 'POST' }" in template
assert hasattr(internet_router, 'reconcile_internet_invoice_reviews')
def test_invoice_reconciliation_is_explicit_and_shared_fiber_has_no_sla_warning():
index_template = Path('app/modules/internet_connections/templates/index.html').read_text()
detail_template = Path('app/modules/internet_connections/templates/detail.html').read_text()
load_body = index_template.split('async function loadInvoiceSyncRuns()', 1)[1].split('async function reconcileInvoiceSyncRuns', 1)[0]
assert "await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile'" not in load_body
assert 'onclick="reconcileInvoiceSyncRuns()"' in index_template
assert "if (isBmcSharedFiber)" in detail_template
assert "banner.className = 'd-none';" in detail_template
def test_unallocated_tab_has_compact_customer_suggestion_workflow(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
query_results = iter([[], []])
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: next(query_results))
response = TestClient(app).get('/api/v1/internet-connections/allocation-overview')
template = Path('app/modules/internet_connections/templates/index.html').read_text()
assert response.status_code == 200
assert response.json() == {'items': []}
assert 'assignSuggestedCustomer' in template
assert 'unique_suggestion' in template
def test_list_connections_supports_allocated_filter(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
assert "ic.customer_id IS NOT NULL" in query
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
response = TestClient(app).get('/api/v1/internet-connections', params={
'allocation_model': 'dedicated', 'allocated_only': 'true',
})
assert response.status_code == 200
assert response.json() == []
def test_allocation_suggestions_return_all_customers_on_exact_address(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: {
'id': 155, 'address': 'Testvej 1, 8000 Aarhus C', 'customer_id': None,
})
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [
{'customer_id': 10, 'customer_name': 'Firma A', 'candidate_address': 'Testvej 1, 8000 Aarhus C', 'address_source': 'customer', 'location_name': None},
{'customer_id': 11, 'customer_name': 'Firma B', 'candidate_address': 'Testvej 1, 8000 Aarhus C', 'address_source': 'location', 'location_name': 'Kontor'},
])
response = TestClient(app).get('/api/v1/internet-connections/155/allocation-suggestions')
assert response.status_code == 200
assert [item['customer_id'] for item in response.json()['items']] == [10, 11]
def test_allocation_suggestions_match_boulevard_abbreviation_and_house_range(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: {
'id': 155, 'address': 'Arnold Nielsens Boulevard 81, 2650 Hvidovre', 'customer_id': None,
})
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [{
'customer_id': 214, 'customer_name': 'Glarmester Svensson ApS',
'candidate_address': 'Arnold Nielsens Blv. 81 - 83, 2650 Hvidovre',
'address_source': 'customer', 'location_name': None,
}])
response = TestClient(app).get('/api/v1/internet-connections/155/allocation-suggestions')
assert response.status_code == 200
assert response.json()['items'][0]['customer_id'] == 214
assert response.json()['items'][0]['match_score'] == 90
def test_connection_detail_has_unallocated_customer_banner_and_subscription_linking():
template = Path('app/modules/internet_connections/templates/detail.html').read_text()
assert 'Forbindelsen er ikke tildelt en kunde' in template
assert '/allocation-suggestions' in template
assert 'customer_id=${customerId}' in template
assert 'saveConnectionAllocation()' in template
assert 'BMC Delefiber' in template
assert 'fieldManualShared' in template
def test_subscription_options_endpoint_returns_lookup_rows(monkeypatch): def test_subscription_options_endpoint_returns_lookup_rows(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router from app.modules.internet_connections.backend import router as internet_router
@ -705,3 +984,16 @@ def test_subscription_options_endpoint_returns_lookup_rows(monkeypatch):
assert response.status_code == 200 assert response.status_code == 200
assert response.json()[0]['subscription_number'] == 'SUB-1001' assert response.json()[0]['subscription_number'] == 'SUB-1001'
def test_connection_detail_has_polished_network_header_and_ip_overview():
template = Path("app/modules/internet_connections/templates/detail.html").read_text()
assert 'id="detailCircuitBadge"' in template
assert 'class="detail-section-nav"' in template
assert 'class="detail-metrics-grid mb-4"' in template
assert 'id="connection-ip"' in template
assert "(summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0)" in template
assert "detail-grid-card ip-range-card" in template
assert "<span class=\"label\">Binding</span>" not in template
assert "Netværksmodel" in template

View File

@ -2,6 +2,7 @@ from datetime import datetime
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
import asyncio import asyncio
import re
import sys import sys
import types import types
@ -163,6 +164,45 @@ def test_case_create_defaults_responsible_to_current_user():
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
def test_case_create_sends_relations_in_atomic_create_payload():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
assert "contact_ids: Object.keys(selectedContacts)" in template
assert "telefoni_opkald_id: telefoniPrefill.callId" in template
assert 'raw_contact_ids = data.get("contact_ids")' in router
assert "INSERT INTO sag_kontakter" in router
assert "UPDATE telefoni_opkald" in router
assert "window.location.href = `/sag/${result.id}/v3`;" in template
assert "Omdirigerer..." not in template
def test_case_detail_has_no_overwriting_relation_functions_or_blocking_alerts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert len(re.findall(r"function\s+removeContact\s*\(", template)) == 1
assert len(re.findall(r"function\s+removeCustomer\s*\(", template)) == 1
assert "#caseAddWorkspaceFooter .btn-primary" in template
assert 'id="caseAddWorkspaceBody"' in template
assert not re.search(r"(?<![\w.])alert\(", template)
assert "reloadCasePreservingContext" in template
def test_case_list_has_direct_entity_links_and_inline_updates():
template = Path("app/modules/sag/templates/index.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ sag.customer_id }}"' in template
assert 'href="/contacts/{{ sag.kontakt_id }}"' in template
assert "updateCaseListField({{ sag.id }}, 'status'" in template
assert "updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id'" in template
def test_case_optional_data_endpoints_do_not_use_expected_404s():
settings_router = Path("app/settings/backend/router.py").read_text(encoding="utf-8")
subscriptions_router = Path("app/subscriptions/backend/router.py").read_text(encoding="utf-8")
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert '"time_multiplier_presets"' in settings_router
assert '"change_request": None' in subscriptions_router
assert "if (changePayload.change_request)" in template
def test_case_v3_contact_actions_and_company_link_include_case_context(): def test_case_v3_contact_actions_and_company_link_include_case_context():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8") template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ customer.id }}"' in template assert 'href="/customers/{{ customer.id }}"' in template
@ -368,3 +408,72 @@ def test_time_tab_and_history_include_linked_case_activities():
assert "renderCaseLinkedActivities(linkedActivities)" in template assert "renderCaseLinkedActivities(linkedActivities)" in template
assert 'event_type": activity_type' in source assert 'event_type": activity_type' in source
assert 'source": "call" if activity_type == "call" else "anydesk"' in source assert 'source": "call" if activity_type == "call" else "anydesk"' in source
def test_sag_list_has_per_user_column_preferences():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
migration = Path("migrations/1023_user_sag_list_columns.sql").read_text()
assert 'id="sagColumnList"' in template
assert 'id="saveSagColumnsBtn"' in template
assert 'draggable="true"' in template
assert "function applySagColumnPreferences()" in template
assert "function saveSagColumnPreferences()" in template
assert "column_order: sagColumnOrder" in template
assert "hidden_columns: Array.from(sagHiddenColumns)" in template
assert "column_order: Optional[List[str]] = None" in source
assert "hidden_columns: Optional[List[str]] = None" in source
assert "ON CONFLICT (user_id)" in source
assert "ADD COLUMN IF NOT EXISTS column_order JSONB" in migration
assert "ADD COLUMN IF NOT EXISTS hidden_columns JSONB" in migration
def test_sag_list_status_dropdown_receives_options_and_links_are_styled():
template = Path("app/modules/sag/templates/index.html").read_text()
views = Path("app/modules/sag/frontend/views.py").read_text()
assert '"status_options": status_options' in views
assert "{% for status_option in status_options %}" in template
assert 'class="sag-entity-link"' in template
assert 'class="sag-id"' in template
assert ".sag-entity-link:hover" in template
assert "sag-inline-select sag-status-select" in template
assert "sag-inline-select sag-owner-select" in template
assert "function applyStatusSelectTone(control)" in template
def test_support_case_close_without_time_requires_explicit_confirmation():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert '"close_without_time_confirmation_required"' in source
assert 'SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s)' in source
assert 'confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True' in source
assert "detail?.code === 'close_without_time_confirmation_required'" in template
assert "body.confirm_close_without_time = true" in template
assert 'id="closeCaseWithoutTimeModal"' in template
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
assert 'data-quick-filter="mine-open"' in template
assert 'data-quick-filter="overdue"' in template
assert 'data-quick-filter="my-groups"' in template
assert 'data-quick-filter="unassigned"' in template
assert 'id="clearSearchBtn"' in template
assert "search.split(/\\s+/).filter(Boolean).every" in template
assert "/sag/me/quick-filter-context" in source
assert "SELECT group_id FROM user_groups WHERE user_id = %s" in source
def test_overdue_active_cases_have_red_row_shadow():
template = Path("app/modules/sag/templates/index.html").read_text()
assert ".sag-table tbody tr.sag-deadline-overdue" in template
assert "function updateOverdueDeadlineMarker(row)" in template
assert "!closedStatuses.has(status)" in template
assert "row.classList.toggle('sag-deadline-overdue', isOverdue)" in template

View File

@ -1,11 +1,21 @@
import asyncio import asyncio
import importlib import importlib
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
timetracking_router = importlib.import_module("app.timetracking.backend.router") timetracking_router = importlib.import_module("app.timetracking.backend.router")
def test_live_timer_rounds_started_minute_up_instead_of_zero():
started = datetime(2026, 8, 30, 10, 0, 0)
entry = {"start_tid": started, "pause_total_seconds": 0, "paused_at": None}
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started) == 0
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started + timedelta(seconds=1)) == 1
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started + timedelta(seconds=60)) == 1
assert timetracking_router._elapsed_minutes_excluding_pause(entry, started + timedelta(seconds=61)) == 2
def test_manual_time_keeps_local_wall_clock_and_creates_one_entry_per_employee(monkeypatch): def test_manual_time_keeps_local_wall_clock_and_creates_one_entry_per_employee(monkeypatch):
captured = {} captured = {}
@ -86,3 +96,17 @@ def test_case_time_form_sends_local_time_and_multiple_employees():
assert "startIso = `${dateValue}T${startValue}:00`;" in quick_time_script assert "startIso = `${dateValue}T${startValue}:00`;" in quick_time_script
assert "endIso = addMinutesToTimeV1LocalIso(startIso, minutes);" in quick_time_script assert "endIso = addMinutesToTimeV1LocalIso(startIso, minutes);" in quick_time_script
assert ".toISOString()" not in quick_time_script assert ".toISOString()" not in quick_time_script
def test_bottom_bar_timer_changes_refresh_case_timer_panel():
bottom_bar = Path("static/js/bottom-bar.js").read_text()
case_template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "notifyTimerStateChanged('paused')" in bottom_bar
assert "notifyTimerStateChanged('resumed'" in bottom_bar
assert "notifyTimerStateChanged('stopped')" in bottom_bar
assert "window.addEventListener('bb:timer-state-changed'" in case_template
assert "event.key === 'bmc:timer-state-changed'" in case_template
assert "timerChip.classList.toggle('is-hidden', !hasActiveTimer && !hasPausedTimer)" in bottom_bar
assert "timerText.textContent = 'Pauset · ' + name" in bottom_bar
assert "const visibleTimer = timer.active ? timer : (paused[0] || {})" in bottom_bar