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:
parent
ffdc9ac62c
commit
adc4fb5876
@ -705,6 +705,82 @@ def _normalize_provider_reference(value: Optional[str]) -> str:
|
||||
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:
|
||||
parts = [f"Afventer mapping for {reference}."]
|
||||
if end_customer_name:
|
||||
@ -757,11 +833,9 @@ def _should_assign_internal_bmc_owner(
|
||||
return False
|
||||
if any(str(line.get("end_customer_name") or "").strip() for line in lines):
|
||||
return False
|
||||
if any(_looks_like_ip_range_line(line) for line in lines):
|
||||
return True
|
||||
if len(lines) > 1:
|
||||
return True
|
||||
return bool(service_address)
|
||||
description = " ".join(str(line.get("description") or "").lower() for line in lines)
|
||||
explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit")
|
||||
return any(marker in description for marker in explicit_shared_markers)
|
||||
|
||||
|
||||
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]:
|
||||
matches = execute_query(
|
||||
canonical_cidr = _canonical_ip_network(cidr)
|
||||
candidates = execute_query(
|
||||
"""
|
||||
SELECT id
|
||||
SELECT id, cidr
|
||||
FROM internet_connections_ip_ranges
|
||||
WHERE connection_id = %s
|
||||
AND cidr = %s
|
||||
AND deleted_at IS NULL
|
||||
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:
|
||||
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]:
|
||||
if not reference:
|
||||
return []
|
||||
match_keys = sorted(_provider_reference_match_keys(reference))
|
||||
rows = execute_query(
|
||||
"""
|
||||
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
|
||||
WHERE deleted_at IS NULL
|
||||
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
|
||||
""",
|
||||
(reference,),
|
||||
(match_keys,),
|
||||
) or []
|
||||
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:
|
||||
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
|
||||
|
||||
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
|
||||
if not matched_customer and existing and existing.get("customer_id"):
|
||||
# Preserve a previously reviewed owner when the new invoice has no
|
||||
# unambiguous customer name/address instead of replacing it with BMC.
|
||||
matched_customer = next(
|
||||
(customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])),
|
||||
None,
|
||||
)
|
||||
# Supplier data may suggest a customer name, but customer ownership is
|
||||
# always a manual CRM decision. Existing manually selected owners survive
|
||||
# because update SQL uses COALESCE(NULL, customer_id); new records stay NULL.
|
||||
matched_customer = None
|
||||
description = str(primary_line.get("description") or reference)
|
||||
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
|
||||
# Internal BMC ownership is only a default for a newly discovered
|
||||
# connection. An existing connection with no customer may deliberately be
|
||||
# unassigned and must not gain an owner merely because a later invoice is
|
||||
# ambiguous.
|
||||
internal_owner = (
|
||||
_resolve_internal_bmc_customer()
|
||||
if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address)
|
||||
else None
|
||||
)
|
||||
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}")
|
||||
)
|
||||
is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address)
|
||||
internal_owner = None
|
||||
owner_customer = None
|
||||
is_confident = False
|
||||
connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}"
|
||||
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")))
|
||||
base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
|
||||
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference)
|
||||
if internal_owner and not matched_customer:
|
||||
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}"
|
||||
note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk."
|
||||
|
||||
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
|
||||
target_status = "active" if (is_confident or internal_owner) else "pending"
|
||||
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
|
||||
target_status = "pending"
|
||||
shared_value_type = "delefiber" if is_shared_candidate else "other"
|
||||
payload = (
|
||||
connection_name,
|
||||
"GlobalConnect A/S",
|
||||
@ -1267,14 +1332,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
upload_mbps,
|
||||
download_mbps,
|
||||
note_text,
|
||||
"shared" if internal_owner and not matched_customer else "dedicated",
|
||||
"shared" if is_shared_candidate else "dedicated",
|
||||
shared_value_type,
|
||||
None,
|
||||
)
|
||||
|
||||
if existing:
|
||||
updated_snapshot = {
|
||||
"customer_id": owner_customer["id"] if owner_customer else None,
|
||||
"customer_id": existing.get("customer_id"),
|
||||
"address": service_address,
|
||||
"monthly_cost": monthly_cost,
|
||||
"technology": _infer_technology(description),
|
||||
@ -1284,7 +1349,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
"download_mbps": download_mbps,
|
||||
"upload_mbps": upload_mbps,
|
||||
"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_label": None,
|
||||
}
|
||||
@ -1301,7 +1366,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
download_mbps,
|
||||
upload_mbps,
|
||||
note_text,
|
||||
"shared" if internal_owner and not matched_customer else "dedicated",
|
||||
"shared" if is_shared_candidate else "dedicated",
|
||||
shared_value_type,
|
||||
None,
|
||||
existing["id"],
|
||||
@ -1333,7 +1398,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
update_payload[1],
|
||||
update_payload[2],
|
||||
update_payload[3],
|
||||
"active" if (is_confident or internal_owner) else "pending",
|
||||
target_status,
|
||||
update_payload[4],
|
||||
update_payload[5],
|
||||
update_payload[6],
|
||||
@ -1398,7 +1463,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
payload[1],
|
||||
payload[2],
|
||||
payload[3],
|
||||
"active" if (is_confident or internal_owner) else "pending",
|
||||
target_status,
|
||||
payload[4],
|
||||
payload[5],
|
||||
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):
|
||||
cidr = str(line.get("ip_address") or "").strip()
|
||||
cidr = _canonical_ip_network(line.get("ip_address"))
|
||||
if not connection_id or not cidr:
|
||||
return None
|
||||
|
||||
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
|
||||
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)
|
||||
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]:
|
||||
"""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:
|
||||
return {"connection_id": None, "conflict_reason": None}
|
||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||
service_address = _build_service_address(line)
|
||||
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.circuit_number AS connection_reference
|
||||
FROM internet_connections_ip_ranges range
|
||||
JOIN internet_connections_connections connection ON connection.id = range.connection_id
|
||||
WHERE range.cidr = %s
|
||||
AND range.deleted_at IS NULL
|
||||
WHERE range.deleted_at IS NULL
|
||||
AND connection.deleted_at IS NULL
|
||||
AND connection.provider ILIKE 'GlobalConnect%%'
|
||||
ORDER BY range.id
|
||||
""",
|
||||
(cidr,),
|
||||
(),
|
||||
) or []
|
||||
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
|
||||
if reference:
|
||||
matching_reference = [
|
||||
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:
|
||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||
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)
|
||||
if existing_range_resolution.get("conflict_reason"):
|
||||
if existing_range_resolution.get("conflict_reason") and not reference_connection_id:
|
||||
skipped_orphan_ip_ranges += 1
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
|
||||
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
|
||||
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
|
||||
corrected_service_address = None
|
||||
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
|
||||
if not connection_id and reference:
|
||||
if not service_address:
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = "Mangler serviceadresse til IP-range"
|
||||
else:
|
||||
connection_id = None
|
||||
if not connection_id:
|
||||
matched_by_reference = False
|
||||
skipped_orphan_ip_ranges += 1
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse"
|
||||
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)
|
||||
if 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]["reason"] = connection_conflict_reason
|
||||
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)
|
||||
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"):
|
||||
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
|
||||
sync_line["circuit_id"] = existing_range_resolution["canonical_reference"]
|
||||
|
||||
@ -89,6 +89,7 @@ class VendorBase(BaseModel):
|
||||
priority: Optional[int] = 100
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_internet_provider: bool = False
|
||||
|
||||
|
||||
class VendorCreate(VendorBase):
|
||||
@ -103,10 +104,16 @@ class VendorUpdate(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
email: 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
|
||||
category: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_internet_provider: Optional[bool] = None
|
||||
|
||||
|
||||
class Vendor(VendorBase):
|
||||
@ -159,6 +166,15 @@ class SolutionBase(BaseModel):
|
||||
description: Optional[str] = None
|
||||
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
|
||||
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):
|
||||
"""Schema for creating a solution"""
|
||||
@ -171,6 +187,16 @@ class SolutionUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
solution_type: 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):
|
||||
"""Full solution schema"""
|
||||
@ -179,6 +205,9 @@ class Solution(SolutionBase):
|
||||
created_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
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)
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from app.core.auth_service import AuthService
|
||||
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_notifications = get_notifications(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({
|
||||
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": initial_status}))
|
||||
await websocket.send_json(jsonable_encoder({
|
||||
"event": "notification_delta",
|
||||
"data": {
|
||||
"notifications": initial_notifications,
|
||||
"messages": initial_messages,
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
last_status_json = json.dumps(initial_status, 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)
|
||||
elapsed = int(timer.get("elapsed") or 0)
|
||||
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
|
||||
|
||||
status_tick += 1
|
||||
@ -110,19 +111,19 @@ async def bottom_bar_ws(websocket: WebSocket):
|
||||
|
||||
status_json = json.dumps(status, sort_keys=True, default=str)
|
||||
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
|
||||
|
||||
notifications_json = json.dumps(notifications, 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:
|
||||
await websocket.send_json({
|
||||
await websocket.send_json(jsonable_encoder({
|
||||
"event": "notification_delta",
|
||||
"data": {
|
||||
"notifications": notifications,
|
||||
"messages": messages,
|
||||
},
|
||||
})
|
||||
}))
|
||||
last_notifications_json = notifications_json
|
||||
last_messages_json = messages_json
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import ipaddress
|
||||
import io
|
||||
import logging
|
||||
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 typing import Any, Dict, List, Optional
|
||||
|
||||
@ -67,6 +71,88 @@ GENERIC_SEGMENT_TITLE_PATTERNS = (
|
||||
"not in use",
|
||||
"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:
|
||||
@ -75,6 +161,22 @@ def _normalize_service_location(value: Optional[str]) -> str:
|
||||
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):
|
||||
line_number: int
|
||||
action: str
|
||||
@ -209,14 +311,19 @@ def _extract_segment_entities(block: str) -> Dict[str, List[str]]:
|
||||
references = []
|
||||
socket_numbers = []
|
||||
|
||||
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}/\d{1,2}\b", block):
|
||||
normalized = raw.strip()
|
||||
raw_cidr_values = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\s*/\s*\d{1,2}\b", block)
|
||||
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:
|
||||
cidr_blocks.append(normalized)
|
||||
|
||||
for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", block):
|
||||
normalized = raw.strip()
|
||||
if any(normalized == cidr.split("/")[0] for cidr in cidr_blocks):
|
||||
if normalized in cidr_literal_ips:
|
||||
continue
|
||||
try:
|
||||
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):
|
||||
network = _validate_cidr(cidr)
|
||||
addresses = []
|
||||
@ -743,7 +916,7 @@ def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
if value_type == "other":
|
||||
if not value_label:
|
||||
value_label = "Mangler klassifikation"
|
||||
raise HTTPException(status_code=400, detail="value_label is required when value_type is other")
|
||||
else:
|
||||
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_label"] = value_label
|
||||
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
|
||||
|
||||
|
||||
@ -762,6 +941,8 @@ def _connection_select_sql(where_sql: str = "") -> str:
|
||||
ic.parent_id,
|
||||
ic.name,
|
||||
ic.provider,
|
||||
ic.vendor_id,
|
||||
vendor.name AS vendor_name,
|
||||
ic.customer_id,
|
||||
c.name AS customer_name,
|
||||
parent.name AS parent_name,
|
||||
@ -784,10 +965,16 @@ def _connection_select_sql(where_sql: str = "") -> str:
|
||||
ic.allocation_model,
|
||||
ic.value_type,
|
||||
ic.value_label,
|
||||
ic.is_manual_shared,
|
||||
ic.subscription_id,
|
||||
ic.sla_subscription_id,
|
||||
sub.subscription_number,
|
||||
sub.product_name AS subscription_product_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.total_addresses, 0) AS total_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
|
||||
FROM internet_connections_connections ic
|
||||
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 sag_subscriptions sub ON sub.id = ic.subscription_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 (
|
||||
SELECT
|
||||
ir.connection_id,
|
||||
@ -919,6 +1108,7 @@ def _build_bmcnet_summary(children: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
class ConnectionCreatePayload(BaseModel):
|
||||
name: str
|
||||
provider: Optional[str] = None
|
||||
vendor_id: Optional[int] = None
|
||||
customer_id: Optional[int] = None
|
||||
parent_id: Optional[int] = None
|
||||
address: Optional[str] = None
|
||||
@ -939,11 +1129,14 @@ class ConnectionCreatePayload(BaseModel):
|
||||
value_type: str = "other"
|
||||
value_label: Optional[str] = None
|
||||
subscription_id: Optional[int] = None
|
||||
sla_subscription_id: Optional[int] = None
|
||||
is_manual_shared: bool = False
|
||||
|
||||
|
||||
class ConnectionUpdatePayload(BaseModel):
|
||||
name: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
vendor_id: Optional[int] = None
|
||||
customer_id: Optional[int] = None
|
||||
parent_id: Optional[int] = None
|
||||
address: Optional[str] = None
|
||||
@ -964,6 +1157,8 @@ class ConnectionUpdatePayload(BaseModel):
|
||||
value_type: Optional[str] = None
|
||||
value_label: Optional[str] = None
|
||||
subscription_id: Optional[int] = None
|
||||
sla_subscription_id: Optional[int] = None
|
||||
is_manual_shared: Optional[bool] = None
|
||||
|
||||
|
||||
class PricingCreatePayload(BaseModel):
|
||||
@ -973,6 +1168,22 @@ class PricingCreatePayload(BaseModel):
|
||||
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):
|
||||
name: 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.actionable_lines, latest.skipped_lines,
|
||||
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,
|
||||
legacy.connection_count AS legacy_connection_count,
|
||||
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")
|
||||
async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest):
|
||||
if data.action not in {"ignore", "link_existing", "create_separate"}:
|
||||
@ -1495,6 +1809,8 @@ async def list_connections(
|
||||
value_type: Optional[str] = Query(None),
|
||||
shared_only: bool = Query(False),
|
||||
bmcnet_only: bool = Query(False),
|
||||
unallocated_only: bool = Query(False),
|
||||
allocated_only: bool = Query(False),
|
||||
):
|
||||
query = _connection_select_sql()
|
||||
params: list[object] = []
|
||||
@ -1528,33 +1844,130 @@ async def list_connections(
|
||||
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"
|
||||
try:
|
||||
rows = execute_query(query, tuple(params) if params else ()) or []
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load internet connections: %s", exc)
|
||||
return []
|
||||
logger.exception("Failed to load internet connections")
|
||||
raise HTTPException(status_code=500, detail="Kunne ikke hente internetforbindelser") from exc
|
||||
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)
|
||||
async def create_connection(payload: ConnectionCreatePayload):
|
||||
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:
|
||||
rows = execute_query(
|
||||
"""
|
||||
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,
|
||||
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 *
|
||||
""",
|
||||
(
|
||||
normalized.get("parent_id"),
|
||||
normalized.get("name"),
|
||||
normalized.get("provider"),
|
||||
normalized.get("vendor_id"),
|
||||
normalized.get("customer_id"),
|
||||
normalized.get("address"),
|
||||
normalized.get("status"),
|
||||
@ -1574,6 +1987,8 @@ async def create_connection(payload: ConnectionCreatePayload):
|
||||
normalized.get("value_type"),
|
||||
normalized.get("value_label"),
|
||||
normalized.get("subscription_id"),
|
||||
normalized.get("sla_subscription_id"),
|
||||
normalized.get("is_manual_shared", False),
|
||||
),
|
||||
)
|
||||
except HTTPException:
|
||||
@ -1598,6 +2013,7 @@ async def create_connection(payload: ConnectionCreatePayload):
|
||||
"subscription_id": normalized.get("subscription_id"),
|
||||
},
|
||||
)
|
||||
_sync_bmcnet_parent_classification(normalized.get("parent_id"))
|
||||
return connection
|
||||
|
||||
|
||||
@ -1623,11 +2039,47 @@ async def update_connection(connection_id: int, payload: ConnectionUpdatePayload
|
||||
):
|
||||
merged_values["value_label"] = "Mangler klassifikation"
|
||||
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 = []
|
||||
params: list[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:
|
||||
value = normalized.get(field)
|
||||
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')}",
|
||||
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])
|
||||
|
||||
|
||||
@ -1860,7 +2318,11 @@ async def pricing_summary():
|
||||
|
||||
|
||||
@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] = []
|
||||
where = ["1=1"]
|
||||
if status and status != "all":
|
||||
@ -1870,6 +2332,9 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que
|
||||
term = f"%{q}%"
|
||||
where.append("(s.subscription_number ILIKE %s OR s.product_name ILIKE %s OR c.name ILIKE %s)")
|
||||
params.extend([term, term, term])
|
||||
if customer_id:
|
||||
where.append("s.customer_id = %s")
|
||||
params.append(customer_id)
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT
|
||||
@ -1878,7 +2343,8 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que
|
||||
s.product_name,
|
||||
s.customer_id,
|
||||
c.name AS customer_name,
|
||||
s.status
|
||||
s.status,
|
||||
s.price
|
||||
FROM sag_subscriptions s
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
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]
|
||||
|
||||
|
||||
@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")
|
||||
async def get_subscription_provisioning(subscription_id: int):
|
||||
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")
|
||||
|
||||
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,),
|
||||
)
|
||||
if not shared_connection:
|
||||
@ -2289,6 +2909,10 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
|
||||
|
||||
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(
|
||||
existing_connection_id,
|
||||
"subscription_provisioned",
|
||||
@ -2326,11 +2950,11 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
|
||||
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
|
||||
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
|
||||
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,),
|
||||
)
|
||||
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))
|
||||
customer = execute_query_single(
|
||||
@ -2627,8 +3251,8 @@ async def list_ip_ranges(connection_id: int):
|
||||
(connection_id,),
|
||||
) or []
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load IP ranges: %s", exc)
|
||||
return []
|
||||
logger.exception("Failed to load IP ranges for connection %s", connection_id)
|
||||
raise HTTPException(status_code=500, detail="Kunne ikke hente IP-ranges") from exc
|
||||
|
||||
address_map: dict[int, list[dict]] = {}
|
||||
for address in address_rows:
|
||||
|
||||
@ -12,14 +12,30 @@
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem;
|
||||
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 {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 20px;
|
||||
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 {
|
||||
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
|
||||
@ -27,7 +43,10 @@
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
@ -55,6 +74,31 @@
|
||||
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 {
|
||||
border-color: rgba(15, 76, 117, 0.28);
|
||||
background: rgba(15, 76, 117, 0.06);
|
||||
@ -235,6 +279,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.detail-read-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@ -245,6 +290,8 @@
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -252,12 +299,16 @@
|
||||
<div class="container-fluid py-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="detail-title-wrap">
|
||||
<div class="detail-title-icon"><i class="bi bi-router"></i></div>
|
||||
<div>
|
||||
<div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div>
|
||||
<h2 class="h3 mb-1" id="detailName">Indlæser...</h2>
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="/economy/internet-connections" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Tilbage
|
||||
@ -276,9 +327,45 @@
|
||||
</button>
|
||||
</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 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="detail-metric">
|
||||
<div class="detail-metric-label">Salgspris</div>
|
||||
@ -314,7 +401,7 @@
|
||||
|
||||
<div class="row g-4">
|
||||
<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">
|
||||
<h5 class="mb-0">Grunddata</h5>
|
||||
<span id="detailStatusBadge" class="status-pill inactive">-</span>
|
||||
@ -327,7 +414,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<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 class="col-md-3">
|
||||
<label class="form-label">Kredsløb</label>
|
||||
@ -383,6 +470,13 @@
|
||||
<label class="form-label">Overvågning</label>
|
||||
<input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://...">
|
||||
</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">
|
||||
<label class="form-label">Allokering</label>
|
||||
<select class="form-select" id="fieldAllocationModel">
|
||||
@ -424,7 +518,7 @@
|
||||
</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>
|
||||
<h5 class="mb-0">IP-ranges</h5>
|
||||
@ -538,16 +632,16 @@
|
||||
</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">
|
||||
<h5 class="mb-0">Pris og kontrakt</h5>
|
||||
<div class="small text-muted" id="pricingFeedback"></div>
|
||||
</div>
|
||||
<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"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></div>
|
||||
<div class="col-md-3"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></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="pricingEffectiveDateInput">Gældende fra</label><input type="date" class="form-control" id="pricingEffectiveDateInput"></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"><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"><label class="form-label" for="pricingNotesInput">Note</label><input type="text" class="form-control" id="pricingNotesInput" placeholder="Valgfri note"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button>
|
||||
@ -580,7 +674,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4">
|
||||
<div class="detail-panel p-4" id="connection-history">
|
||||
<h5 class="mb-3">Historik</h5>
|
||||
<div id="historyList"></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) {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
@ -1000,11 +1101,12 @@
|
||||
}
|
||||
|
||||
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/internet-connections'),
|
||||
fetch('/api/v1/internet-connections/subscription-options?status=active'),
|
||||
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: [] };
|
||||
@ -1012,6 +1114,9 @@
|
||||
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
|
||||
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.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(
|
||||
@ -1058,7 +1163,7 @@
|
||||
}
|
||||
|
||||
function getSharedHeadOptions() {
|
||||
return (connectionOptions || []).filter((item) => item?.is_shared_head);
|
||||
return (connectionOptions || []).filter((item) => !item?.parent_id);
|
||||
}
|
||||
|
||||
async function loadRangesForSharedHead(sharedHeadId) {
|
||||
@ -1169,8 +1274,9 @@
|
||||
}
|
||||
|
||||
function populateBmcnetWizard(connection) {
|
||||
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head);
|
||||
if (!connection?.is_shared_head) return;
|
||||
const canCreateBmcnet = Boolean(connection && !connection.parent_id);
|
||||
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
|
||||
if (!canCreateBmcnet) return;
|
||||
|
||||
document.getElementById('bmcnetWizardCustomerLookup').value = '';
|
||||
document.getElementById('bmcnetWizardCustomerId').value = '';
|
||||
@ -1197,7 +1303,7 @@
|
||||
}
|
||||
|
||||
function openBmcnetWizard() {
|
||||
if (!currentConnection?.is_shared_head) return;
|
||||
if (!currentConnection || currentConnection.parent_id) return;
|
||||
populateBmcnetWizard(currentConnection);
|
||||
if (!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() {
|
||||
await loadLookups();
|
||||
|
||||
@ -1340,15 +1551,26 @@
|
||||
}
|
||||
|
||||
const connection = await connectionResponse.json();
|
||||
const ranges = await rangesResponse.json();
|
||||
const addresses = await addressesResponse.json();
|
||||
const summary = await summaryResponse.json();
|
||||
const pricing = await pricingResponse.json();
|
||||
const pricingHistory = await pricingHistoryResponse.json();
|
||||
const contracts = await contractsResponse.json();
|
||||
const history = await historyResponse.json();
|
||||
const ranges = await safeJson(rangesResponse, []);
|
||||
const addresses = await safeJson(addressesResponse, []);
|
||||
const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 });
|
||||
const pricing = await safeJson(pricingResponse, {});
|
||||
const pricingHistory = await safeJson(pricingHistoryResponse, []);
|
||||
const contracts = await safeJson(contractsResponse, []);
|
||||
const history = await safeJson(historyResponse, []);
|
||||
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;
|
||||
currentAddresses = Array.isArray(addresses) ? addresses : [];
|
||||
currentRanges = Array.isArray(ranges) ? ranges : [];
|
||||
@ -1356,6 +1578,8 @@
|
||||
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
|
||||
: [];
|
||||
|
||||
await renderAllocationBanner(connection);
|
||||
|
||||
renderCore(connection, pricing, summary);
|
||||
renderRanges(currentRanges);
|
||||
renderAddresses(currentAddresses);
|
||||
@ -1406,6 +1630,7 @@
|
||||
|
||||
function renderCore(connection, pricing, summary) {
|
||||
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 businessText = connection.allocation_model === 'shared'
|
||||
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
|
||||
@ -1420,19 +1645,23 @@
|
||||
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
|
||||
const totalDown = Number(connection.download_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);
|
||||
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0);
|
||||
// Only explicit child allocations count as consumed capacity. A nominal
|
||||
// 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 upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
|
||||
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
|
||||
document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
|
||||
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
|
||||
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
|
||||
document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0));
|
||||
document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`;
|
||||
document.getElementById('metricBandwidthSub').textContent = totalUp
|
||||
? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op`
|
||||
: `${downPct}%`;
|
||||
document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0));
|
||||
document.getElementById('metricBandwidth').textContent = childConnections.length
|
||||
? `${usedDown} Mbps allokeret`
|
||||
: '0 Mbps allokeret';
|
||||
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>`;
|
||||
|
||||
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>`
|
||||
: '<span class="detail-read-muted">-</span>';
|
||||
|
||||
renderSla(connection);
|
||||
|
||||
document.getElementById('coreReadView').innerHTML = `
|
||||
<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">Kredsløb</span><span class="value">${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>
|
||||
<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">Teknologi</span><span class="value">${connection.technology || '-'}</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">${escapeHtml(connection.circuit_number || '-')}</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">${escapeHtml(connection.connection_type || '-')}</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>
|
||||
<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">Adresse</span><div class="value">${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">Parent</span><div class="value">${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 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">${escapeHtml(connection.address || '-')}</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">${escapeHtml(connection.parent_name || '-')}</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">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 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>
|
||||
`;
|
||||
|
||||
setStatusBadge(connection.status);
|
||||
|
||||
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('fieldAddress').value = connection.address || '';
|
||||
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
|
||||
@ -1484,6 +1716,7 @@
|
||||
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
|
||||
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
|
||||
document.getElementById('fieldValueType').value = connection.value_type || 'other';
|
||||
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
|
||||
document.getElementById('fieldValueLabel').value = connection.value_label || '';
|
||||
setSubscriptionValue(connection.subscription_id);
|
||||
document.getElementById('fieldContractStart').value = connection.contract_start || '';
|
||||
@ -1493,6 +1726,66 @@
|
||||
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) {
|
||||
coreEditMode = Boolean(force);
|
||||
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
|
||||
@ -1512,27 +1805,38 @@
|
||||
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
|
||||
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="value">${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>
|
||||
${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''}
|
||||
${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''}
|
||||
${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''}
|
||||
${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</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>` : ''}
|
||||
${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<div class="value ip-range-cidr"><i class="bi bi-globe2"></i>${range.cidr || '-'}</div>
|
||||
<div class="small text-muted mt-1">${range.service_address || 'Ingen serviceadresse'}</div>
|
||||
</div>
|
||||
<div class="ip-range-meta">
|
||||
<span class="label">Adresser</span>
|
||||
<div class="value">${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige</div>
|
||||
<div class="small text-muted">${range.usable_hosts || 0} brugbare i alt</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="small text-muted mt-2" id="rangeFeedback-${range.id}"></div>
|
||||
<div class="d-none" id="rangeEdit-${range.id}">
|
||||
<div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div>
|
||||
<div class="ip-range-edit d-none" id="rangeEdit-${range.id}">
|
||||
<div class="range-edit-grid">
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Navn</label>
|
||||
@ -1581,15 +1885,12 @@
|
||||
`).join('');
|
||||
|
||||
const mismatchMarkup = mismatchedRanges.length ? `
|
||||
<div class="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;">
|
||||
<span class="label text-danger">Afvigelser</span>
|
||||
<div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div>
|
||||
${mismatchedRanges.map((range) => `
|
||||
<div class="small mb-2">
|
||||
<strong>${range.cidr}</strong> · ${range.customer_name || 'Ingen kunde'}<br>
|
||||
<span class="text-danger">${range.alignment_warning || 'Matcher ikke forbindelsen'}</span>
|
||||
<div class="ip-range-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-semibold text-danger">${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol</div>
|
||||
${mismatchedRanges.map((range) => `<div class="small mt-1"><strong>${range.cidr}</strong> · ${range.alignment_warning || 'Matcher ikke forbindelsen'}</div>`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
@ -1661,7 +1962,7 @@
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1913,7 +2214,7 @@
|
||||
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
|
||||
const payload = {
|
||||
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,
|
||||
address: document.getElementById('fieldAddress').value.trim() || null,
|
||||
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
|
||||
@ -1929,6 +2230,7 @@
|
||||
value_type: document.getElementById('fieldValueType').value || 'other',
|
||||
value_label: document.getElementById('fieldValueLabel').value.trim() || 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_end: document.getElementById('fieldContractEnd').value || null,
|
||||
notes: document.getElementById('fieldNotes').value.trim() || null,
|
||||
@ -1976,6 +2278,19 @@
|
||||
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() {
|
||||
const feedback = document.getElementById('rangeCreateFeedback');
|
||||
const button = document.getElementById('createRangeButton');
|
||||
@ -2039,7 +2354,7 @@
|
||||
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@ -2049,7 +2364,7 @@
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
alert('Kunne ikke oprette IP-adresse');
|
||||
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
|
||||
return;
|
||||
}
|
||||
document.getElementById('addressIpInput').value = '';
|
||||
@ -2090,7 +2405,7 @@
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
alert('Kunne ikke opdatere IP-adressen');
|
||||
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
|
||||
return;
|
||||
}
|
||||
editingAddressId = null;
|
||||
|
||||
@ -173,13 +173,13 @@
|
||||
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview">
|
||||
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="refreshActiveTab()">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@ -188,10 +188,13 @@
|
||||
<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="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 class="row g-3 mb-4">
|
||||
<div class="row g-3 mb-4" id="connectionsMetrics">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label">Forbindelser</div>
|
||||
@ -200,7 +203,7 @@
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<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>
|
||||
</div>
|
||||
@ -232,7 +235,7 @@
|
||||
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
|
||||
</div>
|
||||
<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 class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
|
||||
@ -296,13 +299,14 @@
|
||||
</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">
|
||||
<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" />
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="">Alle statusser</option>
|
||||
<option value="active">Aktive</option>
|
||||
<option value="pending">Afventer kontrol</option>
|
||||
<option value="planned">Planlagte</option>
|
||||
<option value="inactive">Inaktive</option>
|
||||
<option value="terminated">Opsagte</option>
|
||||
@ -349,13 +353,14 @@
|
||||
</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>
|
||||
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
|
||||
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
|
||||
</div>
|
||||
<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()">
|
||||
<option value="">Alle resultater</option>
|
||||
<option value="success">Gennemført</option>
|
||||
@ -364,6 +369,7 @@
|
||||
<option value="skipped">Sprunget over</option>
|
||||
<option value="not_logged">Ældre uden detaljer</option>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -382,6 +388,36 @@
|
||||
</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-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
@ -391,6 +427,10 @@
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
@ -402,6 +442,65 @@
|
||||
let invoiceSyncItems = [];
|
||||
let activeTab = 'all';
|
||||
let subscriptionOptions = [];
|
||||
let ipNordicPreview = null;
|
||||
let allocationSuggestionsByConnection = new Map();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
}[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) {
|
||||
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
|
||||
@ -431,17 +530,19 @@
|
||||
|
||||
function allocationBadge(item) {
|
||||
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) {
|
||||
const label = item.value_type_label || 'Anden';
|
||||
return `<span class="internet-tag">${label}</span>`;
|
||||
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function currentTabDescription() {
|
||||
if (activeTab === 'shared') return ' i delte hovedforbindelser';
|
||||
if (activeTab === 'bmcnet') return ' i BMCnet';
|
||||
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
|
||||
if (activeTab === 'unallocated') return ' uden allokeret kunde';
|
||||
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 = '') {
|
||||
return '<option value="">Vælg forbindelse…</option>' + allConnections
|
||||
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
|
||||
@ -558,14 +680,33 @@
|
||||
.join('');
|
||||
}
|
||||
|
||||
function openInvoiceReview(runId) {
|
||||
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
|
||||
function toggleResolvedInvoiceLines() {
|
||||
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;
|
||||
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 decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
|
||||
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('showResolvedInvoiceLines').checked = false;
|
||||
const groups = skippedItems.reduce((result, line) => {
|
||||
const reason = line.reason || 'Anden kontrol';
|
||||
(result[reason] ||= []).push(line);
|
||||
@ -577,7 +718,7 @@
|
||||
<div class="vstack gap-2">
|
||||
${lines.map(line => {
|
||||
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="flex-grow-1">
|
||||
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
|
||||
@ -600,7 +741,6 @@
|
||||
</div>
|
||||
</section>
|
||||
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
|
||||
}
|
||||
|
||||
async function submitInvoiceReview(runId, lineNumber, action) {
|
||||
@ -645,22 +785,43 @@
|
||||
if (valueType) params.set('value_type', valueType);
|
||||
if (activeTab === 'shared') params.set('shared_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 {
|
||||
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
|
||||
|
||||
const connections = await safeJson(connectionsResponse, []);
|
||||
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 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 bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
|
||||
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
|
||||
|
||||
document.getElementById('metricTotal').textContent = String(total);
|
||||
document.getElementById('metricActive').textContent = String(active);
|
||||
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
|
||||
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
|
||||
document.getElementById('metricActive').textContent = `${active} / ${pending}`;
|
||||
const focusMetric = activeTab === 'bmcnet'
|
||||
? ['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);
|
||||
|
||||
renderConnections(allConnections);
|
||||
@ -679,23 +840,32 @@
|
||||
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}'">
|
||||
<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="internet-mini">${item.address || '-'}</div>
|
||||
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</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 ${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>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div>${item.customer_name || '-'}</div>
|
||||
<div>${escapeHtml(item.customer_name || '-')}</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>
|
||||
<div>${item.provider || '-'}</div>
|
||||
<div class="internet-mini">${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>` : '')}
|
||||
<div>${escapeHtml(item.provider || '-')}</div>
|
||||
<div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</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>
|
||||
<div>${formatSpeed(item)}</div>
|
||||
@ -713,7 +883,19 @@
|
||||
</a>
|
||||
</td>
|
||||
</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() {
|
||||
@ -721,7 +903,7 @@
|
||||
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
|
||||
const payload = {
|
||||
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,
|
||||
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
|
||||
address: document.getElementById('connectionAddressInput').value.trim() || null,
|
||||
@ -737,6 +919,8 @@
|
||||
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
|
||||
value_label: document.getElementById('connectionValueLabelInput').value.trim() || 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) {
|
||||
@ -769,7 +953,7 @@
|
||||
feedback.textContent = 'Forbindelse oprettet.';
|
||||
[
|
||||
'connectionNameInput',
|
||||
'connectionProviderInput',
|
||||
'connectionVendorInput',
|
||||
'connectionCustomerIdInput',
|
||||
'connectionCircuitInput',
|
||||
'connectionAddressInput',
|
||||
@ -811,7 +995,23 @@
|
||||
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
|
||||
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
|
||||
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() {
|
||||
@ -845,6 +1045,18 @@
|
||||
.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.getElementById('searchInput').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') loadInternetPage();
|
||||
@ -853,7 +1065,7 @@
|
||||
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
|
||||
});
|
||||
toggleCreateValueFields();
|
||||
await loadSubscriptionOptions();
|
||||
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
|
||||
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -419,7 +419,15 @@ class SagBuzzwordSelectionRequest(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]:
|
||||
@ -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"
|
||||
pipeline = data.get("pipeline") if case_type == "pipeline" else None
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
|
||||
if not isinstance(order_items, list):
|
||||
@ -1098,6 +1115,37 @@ async def create_sag(request: Request, data: dict):
|
||||
if not result:
|
||||
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")
|
||||
for item in normalized_items:
|
||||
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):
|
||||
user_id = _get_user_id_from_request(request)
|
||||
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(
|
||||
"""
|
||||
SELECT type_filters
|
||||
FROM user_sag_list_preferences
|
||||
WHERE user_id = %s
|
||||
""",
|
||||
f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
|
||||
(user_id,),
|
||||
) or []
|
||||
if not rows:
|
||||
return {"type_filters": []}
|
||||
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
|
||||
|
||||
raw = rows[0].get("type_filters")
|
||||
parsed = []
|
||||
@ -1291,20 +1337,43 @@ async def get_my_sag_list_preferences(request: Request):
|
||||
seen.add(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:
|
||||
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)
|
||||
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")
|
||||
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
|
||||
user_id = _get_user_id_from_request(request)
|
||||
try:
|
||||
normalized = []
|
||||
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()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
@ -1313,18 +1382,31 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
|
||||
seen.add(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(
|
||||
"""
|
||||
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
|
||||
VALUES (%s, %s::jsonb, NOW())
|
||||
INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
|
||||
VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
type_filters = EXCLUDED.type_filters,
|
||||
column_order = EXCLUDED.column_order,
|
||||
hidden_columns = EXCLUDED.hidden_columns,
|
||||
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:
|
||||
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")
|
||||
@ -1541,6 +1623,7 @@ async def delete_todo_step(step_id: int):
|
||||
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
||||
"""Update a case."""
|
||||
try:
|
||||
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
|
||||
# Check if case exists
|
||||
check = execute_query(
|
||||
"""
|
||||
@ -1562,6 +1645,23 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
||||
|
||||
if "status" in updates:
|
||||
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:
|
||||
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
||||
if "start_date" in updates:
|
||||
|
||||
@ -1,118 +1,231 @@
|
||||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
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.core.auth_dependencies import require_any_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
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"}
|
||||
|
||||
|
||||
def _user_id(current_user: dict) -> Optional[int]:
|
||||
value = current_user.get("id") or current_user.get("user_id")
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
def _clean_list(values) -> list[str]:
|
||||
cleaned, seen = [], set()
|
||||
for value in values or []:
|
||||
item = str(value or "").strip()
|
||||
key = item.casefold()
|
||||
if item and key not in seen:
|
||||
seen.add(key)
|
||||
cleaned.append(item[:100])
|
||||
return cleaned[:30]
|
||||
|
||||
|
||||
def _normalize_payload(data: dict) -> dict:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
|
||||
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||
return result[0] if result else None
|
||||
|
||||
@router.post(
|
||||
"/sag/{sag_id}/solution",
|
||||
response_model=Solution,
|
||||
dependencies=[Depends(case_edit_access)],
|
||||
|
||||
@router.get("/sag/{sag_id}/solution/versions")
|
||||
async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)):
|
||||
solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||
if not solution:
|
||||
return {"items": [], "total": 0}
|
||||
items = execute_query(
|
||||
"""SELECT v.id, v.version_number, v.change_note, v.created_at,
|
||||
COALESCE(u.full_name, u.username) AS changed_by
|
||||
FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id
|
||||
WHERE v.solution_id=%s ORDER BY v.version_number DESC""",
|
||||
(solution["id"],),
|
||||
) or []
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@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),
|
||||
)
|
||||
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")
|
||||
created = result[0]
|
||||
_version_solution(created, user_id, "Løsning oprettet")
|
||||
return created
|
||||
|
||||
# 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 = """
|
||||
INSERT INTO sag_solutions
|
||||
(sag_id, title, description, solution_type, result, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
sag_id,
|
||||
solution.title,
|
||||
solution.description,
|
||||
solution.solution_type,
|
||||
solution.result,
|
||||
getattr(request.state, "user_id", None)
|
||||
@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
|
||||
async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
|
||||
if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)):
|
||||
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
||||
data = _normalize_payload(updates.model_dump(exclude_unset=True))
|
||||
change_note = data.pop("change_note", None)
|
||||
allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
|
||||
fields, params = [], []
|
||||
for key, value in data.items():
|
||||
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
|
||||
|
||||
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(
|
||||
"/sag/{sag_id}/solution",
|
||||
response_model=Solution,
|
||||
dependencies=[Depends(case_edit_access)],
|
||||
@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,),
|
||||
)
|
||||
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
|
||||
if updates.title is not None:
|
||||
set_clauses.append("title = %s")
|
||||
params.append(updates.title)
|
||||
if updates.description is not None:
|
||||
set_clauses.append("description = %s")
|
||||
params.append(updates.description)
|
||||
if updates.solution_type is not None:
|
||||
set_clauses.append("solution_type = %s")
|
||||
params.append(updates.solution_type)
|
||||
if updates.result is not None:
|
||||
set_clauses.append("result = %s")
|
||||
params.append(updates.result)
|
||||
|
||||
if not set_clauses:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
|
||||
params.append(sag_id)
|
||||
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
|
||||
|
||||
result = execute_query(query, tuple(params))
|
||||
if result:
|
||||
logger.info("✅ Solution updated for case: %s", sag_id)
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to update solution")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error updating solution: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to update solution")
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
|
||||
return article
|
||||
|
||||
@ -13,6 +13,33 @@ logger = logging.getLogger(__name__)
|
||||
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:
|
||||
safe_api_path = json.dumps(api_path)
|
||||
safe_title = json.dumps(page_title)
|
||||
@ -203,6 +230,7 @@ async def sager_liste(
|
||||
assigned_group_id: str = Query(None),
|
||||
unassigned: bool = Query(False),
|
||||
include_deferred: bool = Query(False),
|
||||
quick: str = Query(None),
|
||||
):
|
||||
"""Display list of all cases."""
|
||||
try:
|
||||
@ -215,6 +243,7 @@ async def sager_liste(
|
||||
query = """
|
||||
SELECT s.*,
|
||||
c.name as customer_name,
|
||||
sk_first.contact_id AS kontakt_id,
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name,
|
||||
@ -278,7 +307,8 @@ async def sager_liste(
|
||||
query += ")"
|
||||
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()
|
||||
if normalized_status == "all":
|
||||
pass
|
||||
@ -316,6 +346,7 @@ async def sager_liste(
|
||||
fallback_query = """
|
||||
SELECT s.*,
|
||||
c.name as customer_name,
|
||||
NULL::integer AS kontakt_id,
|
||||
'' as kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
NULL::text AS assigned_group_name,
|
||||
@ -439,6 +470,7 @@ async def sager_liste(
|
||||
"relations_map": relations_map,
|
||||
"child_ids": list(child_ids),
|
||||
"statuses": status_options,
|
||||
"status_options": status_options,
|
||||
"all_tags": [t['tag_navn'] for t in all_tags],
|
||||
"current_status": status,
|
||||
"current_priority": normalized_priority,
|
||||
@ -451,16 +483,19 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
"current_unassigned": requested_unassigned,
|
||||
"current_quick_filter": normalized_quick or "all",
|
||||
"closed_statuses": closed_statuses,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("❌ Error displaying case list")
|
||||
fallback_status_options = _fetch_case_status_options()
|
||||
return templates.TemplateResponse("modules/sag/templates/index.html", {
|
||||
"request": request,
|
||||
"sager": [],
|
||||
"relations_map": {},
|
||||
"child_ids": [],
|
||||
"statuses": _fetch_case_status_options(),
|
||||
"statuses": fallback_status_options,
|
||||
"status_options": fallback_status_options,
|
||||
"all_tags": [],
|
||||
"current_status": status,
|
||||
"current_priority": str(priority or "").strip().lower(),
|
||||
@ -473,6 +508,7 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
"current_unassigned": requested_unassigned,
|
||||
"current_quick_filter": str(quick or "").strip().lower() or "all",
|
||||
"closed_statuses": _fetch_closed_case_statuses(),
|
||||
})
|
||||
|
||||
|
||||
@ -475,6 +475,13 @@
|
||||
}, 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 ---
|
||||
const beskrInput = document.getElementById('beskrivelse');
|
||||
if (beskrInput) {
|
||||
@ -494,7 +501,7 @@
|
||||
|
||||
if (!source) {
|
||||
descriptionInput?.focus();
|
||||
alert('Skriv en beskrivelse først.');
|
||||
showCreateError('Skriv en beskrivelse først.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -529,7 +536,7 @@
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
|
||||
} catch (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 {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
@ -999,7 +1006,7 @@
|
||||
if (!anydeskId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(anydeskId);
|
||||
alert('AnyDesk ID kopieret');
|
||||
showSuccessAlert('AnyDesk ID kopieret');
|
||||
} catch (err) {
|
||||
console.error('Copy failed', err);
|
||||
}
|
||||
@ -1012,13 +1019,13 @@
|
||||
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
|
||||
|
||||
if (!name) {
|
||||
alert('Navn er påkrævet');
|
||||
showCreateError('Navn er påkrævet');
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
|
||||
if (!customerId) {
|
||||
alert('Vælg et firma før du opretter hardware');
|
||||
showCreateError('Vælg et firma før du opretter hardware');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1044,7 +1051,7 @@
|
||||
document.getElementById('hardwareAnyDeskIdInput').value = '';
|
||||
await loadHardwareForContacts();
|
||||
} catch (err) {
|
||||
alert('Fejl: ' + err.message);
|
||||
showCreateError('Fejl: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1335,7 +1342,9 @@
|
||||
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
|
||||
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') {
|
||||
@ -1361,63 +1370,9 @@
|
||||
if (response.ok) {
|
||||
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');
|
||||
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('success-text').textContent = "Sag oprettet";
|
||||
window.location.href = `/sag/${result.id}/v3`;
|
||||
}, 1000);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
let errMsg = "Kunne ikke oprette sag";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -299,8 +299,6 @@
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
};
|
||||
|
||||
console.log('Updating case with data:', data);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
||||
method: 'PATCH',
|
||||
@ -310,17 +308,11 @@
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Updated case:', result);
|
||||
|
||||
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
|
||||
await response.json();
|
||||
document.getElementById('success').textContent = `✅ Sag opdateret`;
|
||||
document.getElementById('success').style.display = 'block';
|
||||
setTimeout(() => {
|
||||
window.location.href = `/sag/${caseId}/v3`;
|
||||
}, 1000);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Error response:', errorText);
|
||||
|
||||
@ -5,17 +5,54 @@
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.search-bar {
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 380px;
|
||||
flex: 1 1 460px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
padding: 0.45rem 0.85rem;
|
||||
min-height: 46px;
|
||||
border-radius: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -90,6 +127,21 @@
|
||||
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 {
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: top;
|
||||
@ -117,10 +169,75 @@
|
||||
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 {
|
||||
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);
|
||||
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 {
|
||||
@ -288,6 +405,64 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@ -649,7 +824,8 @@
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
||||
<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;">
|
||||
<i class="bi bi-list-check"></i>
|
||||
</h1>
|
||||
@ -666,11 +842,14 @@
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<i class="bi bi-search search-icon"></i>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="searchInput"
|
||||
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
|
||||
placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..."
|
||||
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 class="top-controls-actions">
|
||||
@ -682,13 +861,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||
<div class="filter-pills">
|
||||
<div class="filter-pill active" data-filter="all">Alle</div>
|
||||
<div class="filter-pill" data-filter="åben">Åbne</div>
|
||||
<div class="filter-pill" data-filter="lukket">Lukkede</div>
|
||||
<div class="sag-quick-filters" aria-label="Hurtigfiltre">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 class="sag-advanced-filters">
|
||||
<div class="type-filter-wrap">
|
||||
<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">
|
||||
@ -746,6 +929,19 @@
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
||||
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
||||
</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>
|
||||
|
||||
<!-- Table -->
|
||||
@ -780,14 +976,15 @@
|
||||
data-status="{{ sag.status }}"
|
||||
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
|
||||
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();">
|
||||
{% if has_relations %}
|
||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
{% 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">
|
||||
@ -796,10 +993,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 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 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>
|
||||
@ -812,13 +1009,15 @@
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
{% set status_raw = sag.status if sag.status else 'åben' %}
|
||||
{% set status_class = status_raw|lower|replace(' ', '-') %}
|
||||
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
|
||||
<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)">
|
||||
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
<div class="owner-cell">
|
||||
{{ initials_bubble(sag.ansvarlig_navn) }}
|
||||
</div>
|
||||
<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)">
|
||||
<option value="">Ikke tildelt</option>
|
||||
{% 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 class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<div class="owner-cell">
|
||||
@ -855,10 +1054,10 @@
|
||||
{% if related_sag and rel.target_id not in seen_targets %}
|
||||
{% set _ = seen_targets.append(rel.target_id) %}
|
||||
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
||||
<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>
|
||||
<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 %}
|
||||
{% 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">
|
||||
@ -867,10 +1066,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 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 class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
||||
{% for rt in all_rel_types %}
|
||||
@ -886,13 +1085,15 @@
|
||||
</td>
|
||||
<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_class = related_status_raw|lower|replace(' ', '-') %}
|
||||
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
|
||||
<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)">
|
||||
{% 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 class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
||||
<div class="owner-cell">
|
||||
{{ initials_bubble(related_sag.ansvarlig_navn) }}
|
||||
</div>
|
||||
<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)">
|
||||
<option value="">Ikke tildelt</option>
|
||||
{% 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 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">
|
||||
@ -938,9 +1139,127 @@
|
||||
</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>
|
||||
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) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@ -1026,11 +1345,35 @@
|
||||
const allRows = document.querySelectorAll('.tree-row');
|
||||
let currentSearch = '';
|
||||
let currentFilter = 'all';
|
||||
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
|
||||
let currentEmployeeId = '';
|
||||
let currentEmployeeGroupIds = new Set();
|
||||
let currentTypes = new Set();
|
||||
let currentAssignees = new Set();
|
||||
let currentGroups = new Set();
|
||||
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 groupFilter = document.getElementById('groupFilter');
|
||||
const assigneeFilterList = document.getElementById('assigneeFilterList');
|
||||
@ -1043,6 +1386,20 @@
|
||||
function applyFilters() {
|
||||
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 => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
const status = String(row.dataset.status || '').toLowerCase();
|
||||
@ -1051,7 +1408,7 @@
|
||||
const groupRaw = String(row.dataset.groupId || '').trim();
|
||||
const assigneeId = assigneeRaw || '__UNASSIGNED__';
|
||||
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 matchesFilter = currentFilter === 'all'
|
||||
|| (currentFilter === 'åben' && !isClosed)
|
||||
@ -1060,7 +1417,7 @@
|
||||
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
|
||||
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
|
||||
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';
|
||||
|
||||
@ -1075,7 +1432,7 @@
|
||||
const childGroupRaw = String(child.dataset.groupId || '').trim();
|
||||
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
|
||||
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 childMatchesFilter = currentFilter === 'all'
|
||||
|| (currentFilter === 'åben' && !childIsClosed)
|
||||
@ -1084,7 +1441,7 @@
|
||||
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
|
||||
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
|
||||
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';
|
||||
});
|
||||
}
|
||||
@ -1195,11 +1552,55 @@
|
||||
|
||||
if (searchInput) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
|
||||
@ -1335,6 +1851,7 @@
|
||||
const data = await res.json();
|
||||
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
|
||||
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
|
||||
applySagColumnPayload(data);
|
||||
applySelectedTypesToUi();
|
||||
applyFilters();
|
||||
} catch (err) {
|
||||
@ -1351,7 +1868,7 @@
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
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 (typeof showNotification === 'function') {
|
||||
|
||||
18
app/modules/sag/templates/knowledge_detail.html
Normal file
18
app/modules/sag/templates/knowledge_detail.html
Normal 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 %}
|
||||
36
app/modules/sag/templates/knowledge_index.html
Normal file
36
app/modules/sag/templates/knowledge_index.html
Normal 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||
@ -145,7 +145,7 @@
|
||||
<input type="date" class="form-control" id="ordersDateTo">
|
||||
</div>
|
||||
<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 class="col-md-3 text-end">
|
||||
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
|
||||
@ -358,7 +358,26 @@
|
||||
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', () => {
|
||||
['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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -273,7 +273,7 @@ async def get_setting(key: str):
|
||||
query = "SELECT * FROM settings WHERE key = %s"
|
||||
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 = """
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
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,))
|
||||
|
||||
if not result and key == "email_default_signature_template":
|
||||
|
||||
@ -228,6 +228,12 @@
|
||||
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 {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
@ -1591,7 +1597,7 @@ if (bmcOriginalFetch) {
|
||||
<script src="/static/js/telefoni.js?v=2.5"></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/bottom-bar.js?v=2.65"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.68"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||
|
||||
@ -549,7 +549,11 @@ async def get_subscription_change_request_by_case(
|
||||
(change_sag_id,),
|
||||
)
|
||||
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(
|
||||
"""SELECT ci.*, s.subscription_number, s.product_name
|
||||
FROM subscription_change_request_items ci
|
||||
|
||||
@ -142,7 +142,10 @@ def _elapsed_minutes_excluding_pause(entry: Dict[str, Any], end: datetime) -> in
|
||||
paused_seconds += _seconds_between(paused_at, end)
|
||||
|
||||
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:
|
||||
@ -2559,7 +2562,7 @@ async def stop_live_timer_v1(
|
||||
faktisk_tid_min = %s,
|
||||
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
|
||||
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,
|
||||
worked_date = COALESCE(worked_date, %s),
|
||||
entry_status = %s,
|
||||
@ -2575,6 +2578,7 @@ async def stop_live_timer_v1(
|
||||
billable_minutes,
|
||||
actual_minutes,
|
||||
billable_minutes,
|
||||
billable_minutes,
|
||||
block_minutes,
|
||||
now.date(),
|
||||
entry_status,
|
||||
|
||||
63
app/vendors/backend/router.py
vendored
63
app/vendors/backend/router.py
vendored
@ -115,6 +115,7 @@ async def list_vendors(
|
||||
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
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),
|
||||
limit: int = Query(50, ge=1, le=100)
|
||||
):
|
||||
@ -135,6 +136,10 @@ async def list_vendors(
|
||||
query += " AND is_active = %s"
|
||||
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"
|
||||
params.extend([limit, skip])
|
||||
|
||||
@ -218,31 +223,69 @@ async def get_vendor_invoices(vendor_id: int):
|
||||
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"])
|
||||
async def create_vendor(vendor: VendorCreate):
|
||||
"""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:
|
||||
query = """
|
||||
INSERT INTO vendors (
|
||||
name, cvr_number, email, phone, address, postal_code, city,
|
||||
website, domain, email_pattern, category, priority, notes, is_active
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
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, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
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.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)
|
||||
if not result or len(result) == 0:
|
||||
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]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating vendor: {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:
|
||||
update_fields.append("phone = %s")
|
||||
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:
|
||||
update_fields.append("address = %s")
|
||||
params.append(vendor.address)
|
||||
@ -302,6 +350,9 @@ async def update_vendor(vendor_id: int, vendor: VendorUpdate):
|
||||
if vendor.is_active is not None:
|
||||
update_fields.append("is_active = %s")
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
51
app/vendors/frontend/vendor_detail.html
vendored
51
app/vendors/frontend/vendor_detail.html
vendored
@ -134,6 +134,9 @@
|
||||
<a class="nav-link" href="#kunder" data-tab="kunder">
|
||||
<i class="bi bi-building me-2"></i>Kunder
|
||||
</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">
|
||||
<i class="bi bi-clock-history me-2"></i>Aktivitet
|
||||
</a>
|
||||
@ -266,6 +269,21 @@
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="aktivitet">
|
||||
<div class="card p-4">
|
||||
@ -356,6 +374,11 @@
|
||||
<input class="form-check-input" type="checkbox" id="editIsActive">
|
||||
<label class="form-check-label" for="editIsActive">Aktiv leverandør</label>
|
||||
</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>
|
||||
</div>
|
||||
<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('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('internetConnectionsNav').classList.toggle('d-none', !vendor.is_internet_provider);
|
||||
if (vendor.is_internet_provider) loadVendorInternetConnections();
|
||||
|
||||
// Update page title
|
||||
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) {
|
||||
const tbody = document.getElementById('invoicesTableBody');
|
||||
const count = document.getElementById('invoiceCount');
|
||||
@ -832,6 +879,7 @@ function editVendor() {
|
||||
document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || '';
|
||||
document.getElementById('editNotes').value = vendor.notes || '';
|
||||
document.getElementById('editIsActive').checked = vendor.is_active;
|
||||
document.getElementById('editIsInternetProvider').checked = Boolean(vendor.is_internet_provider);
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editVendorModal')).show();
|
||||
})
|
||||
@ -854,7 +902,8 @@ async function saveVendor() {
|
||||
city: document.getElementById('editCity').value.trim() || null,
|
||||
economic_supplier_number: document.getElementById('editEconomicNumber').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) {
|
||||
|
||||
251
app/vendors/frontend/vendors.html
vendored
251
app/vendors/frontend/vendors.html
vendored
@ -51,6 +51,28 @@
|
||||
font-weight: bold;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -129,42 +151,80 @@
|
||||
|
||||
<!-- Create Vendor Modal -->
|
||||
<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-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>
|
||||
</div>
|
||||
<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="col-md-8">
|
||||
<label class="form-label">Virksomhedsnavn *</label>
|
||||
<input type="text" class="form-control" id="name" required>
|
||||
<label class="form-label" for="name">Virksomhedsnavn <span class="text-danger">*</span></label>
|
||||
<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 class="col-md-4">
|
||||
<label class="form-label">CVR-nummer</label>
|
||||
<input type="text" class="form-control" id="cvr_number" maxlength="8">
|
||||
<label class="form-label" for="cvr_number">CVR-nummer</label>
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email">
|
||||
<label class="form-label" for="category">Kategori</label>
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">Telefon</label>
|
||||
<input type="text" class="form-control" id="phone">
|
||||
<label class="form-label" for="domain">Domæne</label>
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">Website</label>
|
||||
<input type="url" class="form-control" id="website">
|
||||
<label class="form-label" for="phone">Telefon</label>
|
||||
<input type="tel" class="form-control" id="phone" autocomplete="tel">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Domain</label>
|
||||
<input type="text" class="form-control" id="domain" placeholder="example.com">
|
||||
<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>
|
||||
</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">
|
||||
<label class="form-label">Adresse</label>
|
||||
<input type="text" class="form-control" id="address">
|
||||
<label class="form-label" for="address">Adresse</label>
|
||||
<input type="text" class="form-control" id="address" autocomplete="street-address">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Postnummer</label>
|
||||
@ -174,28 +234,20 @@
|
||||
<label class="form-label">By</label>
|
||||
<input type="text" class="form-control" id="city">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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-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>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Noter</label>
|
||||
<textarea class="form-control" id="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createVendor()">
|
||||
<i class="bi bi-check-lg me-2"></i>Opret Leverandør
|
||||
<button type="submit" form="createVendorForm" class="btn btn-primary" id="createVendorSubmitBtn">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -354,27 +406,109 @@ function nextPage() {
|
||||
}
|
||||
|
||||
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'));
|
||||
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 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 = {
|
||||
name: document.getElementById('name').value,
|
||||
cvr_number: document.getElementById('cvr_number').value || null,
|
||||
email: document.getElementById('email').value || null,
|
||||
phone: document.getElementById('phone').value || null,
|
||||
website: document.getElementById('website').value || null,
|
||||
domain: document.getElementById('domain').value || null,
|
||||
address: document.getElementById('address').value || null,
|
||||
postal_code: document.getElementById('postal_code').value || null,
|
||||
city: document.getElementById('city').value || null,
|
||||
name: document.getElementById('name').value.trim(),
|
||||
cvr_number: cvrInput.value || null,
|
||||
email: document.getElementById('email').value.trim().toLowerCase() || null,
|
||||
phone: document.getElementById('phone').value.trim() || null,
|
||||
website: websiteValue ? (/^https?:\/\//i.test(websiteValue) ? websiteValue : `https://${websiteValue}`) : null,
|
||||
domain: normalizeDomain(document.getElementById('domain').value) || null,
|
||||
address: document.getElementById('address').value.trim() || null,
|
||||
postal_code: document.getElementById('postal_code').value.trim() || null,
|
||||
city: document.getElementById('city').value.trim() || null,
|
||||
category: document.getElementById('category').value,
|
||||
priority: parseInt(document.getElementById('priority').value),
|
||||
notes: document.getElementById('notes').value || null,
|
||||
is_active: true
|
||||
notes: document.getElementById('notes').value.trim() || null,
|
||||
is_active: document.getElementById('is_active').checked,
|
||||
is_internet_provider: document.getElementById('is_internet_provider').checked
|
||||
};
|
||||
|
||||
try {
|
||||
@ -384,16 +518,16 @@ async function createVendor() {
|
||||
body: JSON.stringify(vendor)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('createVendorModal')).hide();
|
||||
form.reset();
|
||||
loadVendors();
|
||||
} else {
|
||||
alert('Fejl ved oprettelse af leverandør');
|
||||
}
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || 'Leverandøren kunne ikke oprettes.');
|
||||
setCreateVendorFeedback('Leverandøren er oprettet. Åbner leverandørkortet…', 'success');
|
||||
window.setTimeout(() => { window.location.href = `/vendors/${payload.id}`; }, 350);
|
||||
} catch (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
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
3
migrations/1023_user_sag_list_columns.sql
Normal file
3
migrations/1023_user_sag_list_columns.sql
Normal 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;
|
||||
19
migrations/1024_internet_connections_manual_delefiber.sql
Normal file
19
migrations/1024_internet_connections_manual_delefiber.sql
Normal 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')
|
||||
)
|
||||
);
|
||||
@ -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;
|
||||
59
migrations/1026_internet_provider_vendors.sql
Normal file
59
migrations/1026_internet_provider_vendors.sql
Normal 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();
|
||||
73
migrations/233_case_solution_knowledge_base.sql
Normal file
73
migrations/233_case_solution_knowledge_base.sql
Normal 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 = '';
|
||||
|
||||
34
plan-websiteContentAdministration.prompt.md
Normal file
34
plan-websiteContentAdministration.prompt.md
Normal 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 mock’et 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.
|
||||
100
scripts/reconcile_known_internet_connections.py
Normal file
100
scripts/reconcile_known_internet_connections.py
Normal 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())
|
||||
@ -31,6 +31,79 @@ def latest_globalconnect_extractions() -> list[dict]:
|
||||
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:
|
||||
summary: dict[str, int] = {}
|
||||
counts = execute_query_single(
|
||||
@ -94,7 +167,7 @@ def reset_all_internet_data() -> dict:
|
||||
return summary
|
||||
|
||||
|
||||
def rebuild_from_globalconnect() -> dict:
|
||||
def rebuild_from_globalconnect(manual_allocations: list[dict] | None = None) -> dict:
|
||||
extractions = latest_globalconnect_extractions()
|
||||
results = []
|
||||
totals = defaultdict(int)
|
||||
@ -110,7 +183,9 @@ def rebuild_from_globalconnect() -> dict:
|
||||
)
|
||||
if not extraction:
|
||||
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 = {
|
||||
"file_id": extraction_stub["file_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_ip_range_lines"] += result_summary["skipped_ip_range_lines"]
|
||||
|
||||
restored_allocations = restore_manual_allocations(manual_allocations or [])
|
||||
counts = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
@ -151,6 +227,7 @@ def rebuild_from_globalconnect() -> dict:
|
||||
"active_ip_ranges": int(counts.get("active_ip_ranges") 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()
|
||||
|
||||
init_db()
|
||||
manual_allocations = snapshot_manual_allocations()
|
||||
payload = {
|
||||
"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))
|
||||
return 0
|
||||
|
||||
@ -1180,14 +1180,23 @@
|
||||
|
||||
const timer = ((latestSections || {}).timer || {}).active || {};
|
||||
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;
|
||||
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) {
|
||||
const elapsed = timer.elapsed_hhmmss || '00:00:00';
|
||||
const name = timer.sag_navn || ('Sag #' + (timer.sag_id || ''));
|
||||
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) {
|
||||
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>';
|
||||
}
|
||||
if (stopBtn) {
|
||||
@ -1905,6 +1915,16 @@
|
||||
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() {
|
||||
return fetch('/api/v1/timetracking/time/pause', {
|
||||
method: 'POST',
|
||||
@ -1913,7 +1933,7 @@
|
||||
body: '{}'
|
||||
}).then(function (res) {
|
||||
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 {}; });
|
||||
});
|
||||
@ -1932,7 +1952,7 @@
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (res) {
|
||||
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 {}; });
|
||||
});
|
||||
@ -2777,21 +2797,43 @@
|
||||
const paused = Array.isArray(own.paused) ? own.paused : [];
|
||||
|
||||
if (activeTimer.active) {
|
||||
pauseBtn.disabled = true;
|
||||
pauseActiveTimer()
|
||||
.then(fetchBottomBarState)
|
||||
.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) {
|
||||
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;
|
||||
}
|
||||
|
||||
const pausedTimeId = Number((((paused[0] || {}).time_entry_id) || ((paused[0] || {}).id) || 0));
|
||||
pauseBtn.disabled = true;
|
||||
resumeTimer(pausedTimeId || null)
|
||||
.then(fetchBottomBarState)
|
||||
.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) {
|
||||
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()
|
||||
.then(fetchBottomBarState)
|
||||
.then(applyState)
|
||||
.then(function () { notifyTimerStateChanged('stopped'); })
|
||||
.catch(function (err) {
|
||||
console.warn('Failed stopping timer', err);
|
||||
});
|
||||
@ -2816,7 +2859,10 @@
|
||||
if (timerChip) {
|
||||
timerChip.addEventListener('click', function () {
|
||||
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';
|
||||
});
|
||||
}
|
||||
@ -3098,6 +3144,7 @@
|
||||
|
||||
if (switchAction === 'pause-now') {
|
||||
pauseActiveTimer().then(function () {
|
||||
notifyTimerStateChanged('paused');
|
||||
switchCaseState.decision = 'pause';
|
||||
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.');
|
||||
@ -3110,6 +3157,7 @@
|
||||
|
||||
if (switchAction === 'stop-now') {
|
||||
stopActiveTimer().then(function () {
|
||||
notifyTimerStateChanged('stopped');
|
||||
switchCaseState.decision = 'stop';
|
||||
switchCaseState.activeTimer = null;
|
||||
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.');
|
||||
|
||||
@ -99,12 +99,14 @@ def test_sync_globalconnect_extraction_creates_connections_and_ip_ranges(monkeyp
|
||||
assert created_ranges
|
||||
assert ensured_ranges
|
||||
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][10] == 100
|
||||
assert created_connections[0][11] == 100
|
||||
assert created_connections[0][13] == "dedicated"
|
||||
assert created_connections[0][14] == "other"
|
||||
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):
|
||||
@ -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_active_customers_for_matching", lambda: [])
|
||||
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)
|
||||
|
||||
@ -219,7 +222,7 @@ def test_upsert_globalconnect_connection_requires_service_address(monkeypatch):
|
||||
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 = []
|
||||
|
||||
extraction = {
|
||||
@ -264,13 +267,14 @@ def test_sync_globalconnect_assigns_shared_bmc_value_model(monkeypatch):
|
||||
|
||||
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
|
||||
|
||||
assert result["connections_synced"] == 0
|
||||
assert result["ip_ranges_synced"] == 0
|
||||
assert result["skipped_orphan_ip_ranges"] == 1
|
||||
assert created_connections == []
|
||||
assert result["connections_synced"] == 1
|
||||
assert result["ip_ranges_synced"] == 1
|
||||
assert result["skipped_orphan_ip_ranges"] == 0
|
||||
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 = []
|
||||
line = {
|
||||
"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 created_connections
|
||||
assert created_connections[0][2] == 1662
|
||||
assert created_connections[0][-3] == "shared"
|
||||
assert created_connections[0][-2] == "delefiber"
|
||||
assert created_connections[0][2] is None
|
||||
assert created_connections[0][-3] == "dedicated"
|
||||
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):
|
||||
@ -511,6 +574,7 @@ def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
|
||||
"execute_query",
|
||||
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)
|
||||
|
||||
@ -519,7 +583,7 @@ def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
|
||||
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_id": 180,
|
||||
"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)
|
||||
return 96
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
assert result["connections_synced"] == 1
|
||||
assert result["ip_ranges_synced"] == 0
|
||||
assert result["skipped_orphan_ip_ranges"] == 1
|
||||
assert result["ip_ranges_synced"] == 1
|
||||
assert result["skipped_orphan_ip_ranges"] == 0
|
||||
assert created_connections
|
||||
skipped_ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"]
|
||||
assert skipped_ip_entries
|
||||
assert skipped_ip_entries[0]["status"] == "skipped"
|
||||
assert "ingen forbindelse fundet" in (skipped_ip_entries[0]["reason"] or "").lower()
|
||||
ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"]
|
||||
assert ip_entries[0]["status"] == "synced"
|
||||
assert ip_entries[0]["matched_by"] == "unique_circuit_reference"
|
||||
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):
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import sys
|
||||
import asyncio
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@ -9,6 +11,33 @@ from fastapi.testclient import TestClient
|
||||
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():
|
||||
client = TestClient(app)
|
||||
|
||||
@ -22,7 +51,6 @@ def test_internet_connections_module_routes_are_available():
|
||||
'Internetforbindelser' in page_response.text
|
||||
or "window.location.href = '/login'" in page_response.text
|
||||
)
|
||||
|
||||
detail_response = client.get('/economy/internet-connections/1')
|
||||
assert detail_response.status_code == 200
|
||||
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):
|
||||
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']
|
||||
|
||||
|
||||
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):
|
||||
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() == []
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
response = client.get('/api/v1/internet-connections')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
assert response.status_code == 500
|
||||
assert response.json()['detail'] == 'Kunne ikke hente internetforbindelser'
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
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.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
|
||||
|
||||
@ -2,6 +2,7 @@ from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
|
||||
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 'event_type": activity_type' 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
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
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 "endIso = addMinutesToTimeV1LocalIso(startIso, minutes);" 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user