Compare commits

..

10 Commits

Author SHA1 Message Date
Christian
761753e577 chore(release): bump version to 2.5.0 2026-08-18 07:14:43 +02:00
Christian
0920f2ce30 feat: manage case timers from time usage tab 2026-08-18 06:56:36 +02:00
Christian
97218d61a8 fix: separate timer conversion workflow from case switch 2026-08-17 20:53:08 +02:00
Christian
cabe0395ed fix: open timer conversion from bottom bar tab 2026-08-17 20:43:52 +02:00
Christian
9ac5b91f6f fix: harden bottom bar timers messages and access 2026-08-17 19:34:39 +02:00
Christian
88f7cec478 checkpoint: before bottom bar and side panel fixes 2026-08-17 19:27:43 +02:00
Christian
533a652337 feat: Add product creation functionality for ALSO import lines
- Implemented a button to create a product in Hub for unmatched ALSO lines in the frontend.
- Added a new API endpoint to handle product creation from an ALSO import line.
- Enhanced the service layer to create a Hub product and map it to the corresponding line.
- Updated the bottom bar to include support case management features, including assigning cases to technicians.
- Improved the UI for displaying unassigned support cases and added functionality for assigning cases to users.
- Refactored CSS styles for better visual consistency in the bottom bar and support queue cards.
2026-08-12 07:20:04 +02:00
Christian
56cc1bdcc4 feat: Enhance AnyDesk integration and vendor email domain management
- Updated the AnyDesk quick connect modal to improve user experience with new UI elements and functionality.
- Added support for saving and managing multiple AnyDesk IDs associated with cases, including hardware and contact information.
- Implemented backend endpoints for managing vendor email domains, allowing addition, deletion, and retrieval of domains linked to vendors.
- Created a new database table for vendor email domains to support multiple exact domains per vendor.
- Added tests for bankruptcy workflow to ensure correct case creation and alert linking based on exact CVR matches.
2026-07-30 20:11:43 +02:00
Christian
d81a8f41b4 feat: Add end-to-end tests for Sag module HTTP API
- Implemented a comprehensive end-to-end testing script for the Sag module's HTTP API, covering various functionalities including case creation, updates, and file uploads.
- Introduced a safe HTML sanitizer utility to ensure safe rendering of HTML content in the BMC Hub UI.
- Added database migrations for new features including WAN connection marking for wall outlets, permanent audit trails for supplier invoices, and dedicated permissions for the Sag module.
- Created a migration center for manual subscription and invoice migrations with relevant tables and indexes.
- Added tests for migration center functionalities, ensuring stability and correctness of the new features.
2026-07-28 14:18:24 +02:00
Christian
428ad21132 feat(locations): add cross-fields overview page and integrate UISP device data 2026-07-23 21:32:56 +02:00
108 changed files with 13036 additions and 1338 deletions

View File

@ -2,6 +2,7 @@
# POSTGRESQL DATABASE - Local Development
# =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk
# Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub
@ -171,4 +172,4 @@ EMAIL_PROCESS_INTERVAL_MINUTES=5
EMAIL_WORKFLOWS_ENABLED=true
EMAIL_WORKFLOW_AUTORUN_ENABLED=false
EMAIL_MAX_UPLOAD_SIZE_MB=50
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip

View File

@ -24,6 +24,7 @@ GITHUB_REPO=ct/bmc_hub
# POSTGRESQL DATABASE - Production
# =====================================================
DATABASE_URL=postgresql://bmc_hub_prod:CHANGE_THIS_PASSWORD@postgres:5432/bmc_hub_prod
HUB_BASE_URL=https://hub.bmcnetworks.dk
# Database credentials (bruges af docker-compose/podman-compose)
POSTGRES_USER=bmc_hub_prod

View File

@ -0,0 +1,64 @@
# Release Notes: v2.5.0
**Dato:** 18. august 2026
## Overblik
Version 2.5 samler den store omlægning af sager, tidsregistrering og bundmenuen samt nye arbejdsgange for kontakter, abonnementer, lokationer, netværk, leverandørdata og fakturakontrol.
## Sager og tidsregistrering
- Timeren fortsætter korrekt efter sideskift og genindlæsning.
- En medarbejder kan kun have én aktiv timer, men flere pausede timere.
- Aktive, pausede og afsluttede timere kan åbnes og håndteres fra bundmenuen.
- Afsluttede timere kan registreres som fakturering, klippekort, abonnement eller intern tid.
- Arbejdstid kan klassificeres, blandt andet som support, udvikling og vedligehold.
- Fanen **Tidsforbrug** på en sag viser og håndterer timere, som tilhører sagen.
- Den aktive timer vises på den tilhørende sag på tværs af sagens faner.
- Timervisning og dialogen **Skift sag** er gjort mere kompakt og viser tydeligere sag, kunde, kontakt, status og tidsforbrug.
- Sagsfiltre, herunder hastesager, sender nu til de rigtige filtrerede visninger.
- Advarsel og bekræftelse ved lukning af en sag uden registreret tid.
## Bundmenu og sidepaneler
- Timer-, besked-, opgave-, note- og sagsfordelingsvisninger er omarbejdet og bruger pladsen bedre.
- Valgt fane og åben/lukket tilstand bevares under navigation.
- Beskeder har individuelle læse- og kvitteringsstatusser pr. bruger.
- Lederadgang til medarbejdertimere er strammet op, og almindelige brugere ser kun egne timere.
- Timerkonvertering åbner som en særskilt arbejdsgang og virker direkte fra timerfanen.
- Links fra bundmenuen bruger de kanoniske sagssider.
## Kontakter og kundehistorik
- Dublerede kontakter kan flettes med relaterede sager, samtaler, e-mails og historik.
- Kontaktsøgning foreslår automatisk brugerens navn i de relevante arbejdsgange.
- Samlet kontakt- og aktivitetshistorik bevares ved fletning.
## Abonnement, drift og rapportering
- Abonnement er en tydelig sagstype og en selvstændig arbejdsgang på sagen.
- Tidsregistrering understøtter udvikling og vedligehold som rapporterbare typer.
- Data kan rapporteres på tværs af sager, medarbejdere, arbejdstyper og afregningsformer.
## Øvrige større forbedringer
- Udvidet lokations-, krydsfelt-, vægudtags- og UISP/netværksoverblik.
- Forbedret migrationscenter og sagsoprettelse fra migreringsdata.
- Udvidet AnyDesk-integration og administration af leverandørers e-maildomæner.
- Oprettelse af produkter fra ALSO-importlinjer.
- Forbedringer til leverandørfakturaer og fakturafejlfinder.
- Sikkerhedsrettelser til ejerskabskontrol, lederadgang og visning af brugeroprettet indhold.
## Databaseændringer
Releasen indeholder de nødvendige migrationer til timer-, besked-, kontakt-, lokations-, abonnements- og integrationsfunktionerne. Migrationerne køres ved normal opstart/deploy.
## Verifikation
- Målrettede sags- og danske UI-tests: **26 bestået**.
- Samlet `tests/`-suite: **104 bestået, 7 kendte test-isoleringsfejl** i ældre databaseafhængige tests.
- Python-kompilering, JavaScript-syntakskontrol og applikationens health-check er bestået.
## Tilbageførsel
Tilstanden før bundmenu- og timergennemgangen er bevaret i branch `checkpoint-before-bottom-bar-review-20260817` på commit `88f7cec`.

View File

@ -1 +1 @@
2.3.30
2.5.0

View File

@ -10,11 +10,11 @@
<div class="card-body p-4">
<div class="text-center mb-4">
<h2 class="fw-bold" style="color: var(--primary-color);">2FA Setup</h2>
<p class="text-muted">Opsaet tofaktor for din konto</p>
<p class="text-muted">Opsæt tofaktor for din konto</p>
</div>
<div id="statusMessage" class="alert alert-info" role="alert">
Klik "Generer 2FA" for at starte opsaetningen.
Klik "Generer 2FA" for at starte opsætningen.
</div>
<div class="d-grid gap-2 mb-3">

View File

@ -126,7 +126,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
if (data.requires_2fa_setup) {
const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?');
const goSetup = confirm('2FA er ikke opsat. Vil du opsætte 2FA nu?');
window.location.href = goSetup ? '/2fa/setup' : '/';
return;
}

View File

@ -5,6 +5,7 @@ Sends rich formatted notifications to Mattermost webhook
import logging
import aiohttp
import json
from datetime import datetime
from typing import Dict, Optional, List
from app.core.config import settings
@ -346,7 +347,7 @@ class MattermostNotification:
"""
if not self.enabled or not self.webhook_url:
logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id)
return
return False, "Mattermost is disabled or webhook URL is missing"
try:
async with aiohttp.ClientSession() as session:
@ -355,21 +356,63 @@ class MattermostNotification:
logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id)
# Log to database
execute_insert(
"""INSERT INTO backup_notifications
(backup_job_id, event_type, message, mattermost_payload)
VALUES (%s, %s, %s, %s)""",
(job_id, event_type, payload.get('text', ''), str(payload))
)
if job_id is not None:
execute_insert(
"""INSERT INTO backup_notifications
(backup_job_id, event_type, message, mattermost_payload)
VALUES (%s, %s, %s, %s)""",
(job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False))
)
return True, "Mattermost notification sent"
else:
error_text = await response.text()
logger.error("❌ Notification failed: HTTP %s - %s",
response.status, error_text)
should_retry_form = (
response.status in (400, 404, 415)
and (
"media type application/json" in error_text.lower()
or "incoming_webhook.general.app_error" in error_text.lower()
)
)
if not should_retry_form:
logger.error("❌ Notification failed: HTTP %s - %s",
response.status, error_text)
return False, f"Mattermost returned HTTP {response.status}: {error_text[:300]}"
form_data = aiohttp.FormData()
form_data.add_field("payload", json.dumps(payload, ensure_ascii=False))
async with session.post(self.webhook_url, data=form_data, timeout=10) as fallback_response:
fallback_text = await fallback_response.text()
if fallback_response.status == 200:
logger.info("📢 Notification sent using Mattermost form compatibility mode: %s", event_type)
if job_id is not None:
execute_insert(
"""INSERT INTO backup_notifications
(backup_job_id, event_type, message, mattermost_payload)
VALUES (%s, %s, %s, %s)""",
(job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False))
)
return True, "Mattermost notification sent using compatibility mode"
logger.error(
"❌ Mattermost compatibility request failed: HTTP %s - %s",
fallback_response.status,
fallback_text,
)
if "incoming_webhook.general.app_error" in fallback_text:
return False, (
"Mattermost rejected the webhook ID. Create a new Incoming Webhook "
"in Mattermost and use its complete generated /hooks/... URL."
)
return False, (
f"Mattermost returned HTTP {fallback_response.status} "
f"in compatibility mode: {fallback_text[:300]}"
)
except aiohttp.ClientError as e:
logger.error("❌ Notification connection error: %s", str(e))
return False, f"Mattermost connection error: {e}"
except Exception as e:
logger.error("❌ Notification error: %s", str(e))
return False, f"Mattermost notification error: {e}"
def _should_send_notification(self, event_type: str) -> bool:
"""Check if notification should be sent based on settings"""
@ -386,8 +429,7 @@ class MattermostNotification:
def _get_hub_url(self) -> str:
"""Get BMC Hub base URL for action buttons"""
# TODO: Add HUB_BASE_URL to config
return "http://localhost:8000" # Fallback
return str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
# Singleton instance

View File

@ -25,6 +25,17 @@ logger = logging.getLogger(__name__)
router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb"
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
"address",
"service_address",
"monthly_cost",
"technology",
"connection_type",
"circuit_number",
"speed_mbps",
"download_mbps",
"upload_mbps",
}
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
@ -256,7 +267,15 @@ def _ensure_internet_change_case(
owner_customer_id: Optional[int],
changes: Dict[str, Dict[str, object]],
) -> Optional[int]:
if not changes:
# Customer ownership, initial activation and internal classification are
# bookkeeping outcomes of a successful import, not operational incidents.
# Only create cases for changes that can affect delivery or billing.
relevant_changes = {
field: change
for field, change in (changes or {}).items()
if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS
}
if not relevant_changes:
return None
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
@ -287,7 +306,7 @@ def _ensure_internet_change_case(
return None
change_lines = "\n".join(
f"- {field}: {change.get('from')} -> {change.get('to')}"
for field, change in changes.items()
for field, change in relevant_changes.items()
)
description = (
"Automatisk oprettet ved import af internetfaktura.\n"
@ -769,7 +788,7 @@ def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) ->
normalized_target = _normalize_company_name(end_customer_name)
address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()]
best_match = None
best_matches = []
best_score = 0
for customer in customers:
customer_name = str(customer.get("name") or "").strip()
@ -806,9 +825,17 @@ def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) ->
if score > best_score:
best_score = score
best_match = customer
best_matches = [customer]
elif score == best_score and score > 0:
best_matches.append(customer)
return best_match if best_score >= 50 else None
if best_score < 50 or not best_matches:
return None
# An address shared by several tenants is not enough to select a customer.
# Require a unique winner unless the invoice also supplied a customer name.
if len(best_matches) > 1 and not normalized_target:
return None
return best_matches[0]
def _looks_like_ip_range_line(line: Dict) -> bool:
@ -1166,7 +1193,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
)
primary_line = sorted_lines[0]
display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip()
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
service_address = _build_service_address(primary_line)
if not service_address:
logger.warning(
@ -1175,9 +1201,40 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
invoice_number,
)
return None
existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address)
existing = existing_resolution.get("row")
conflict_reason = existing_resolution.get("conflict_reason")
if conflict_reason:
logger.warning(
"Skipping GlobalConnect connection %s from invoice %s because %s",
display_reference or reference,
invoice_number,
conflict_reason,
)
return None
merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])]
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,
)
description = str(primary_line.get("description") or reference)
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
internal_owner = _resolve_internal_bmc_customer() if _should_assign_internal_bmc_owner(lines, matched_customer, service_address) else None
# 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 = (
@ -1194,21 +1251,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
else:
note_text = base_note if is_confident else f"{base_note} {mapping_note}"
existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address)
existing = existing_resolution.get("row")
conflict_reason = existing_resolution.get("conflict_reason")
if conflict_reason:
logger.warning(
"Skipping GlobalConnect connection %s from invoice %s because %s",
display_reference or reference,
invoice_number,
conflict_reason,
)
return None
merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])]
if existing and merge_ids:
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
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)
@ -1540,13 +1582,76 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
)
if not row:
return False
if str(row.get("allocation_model") or "").lower() == "shared":
return True
if not service_address:
return True
# A shared connection may serve several customers, but a supplier reference
# and its IP-range must still belong to the same physical service address.
# Bypassing the address check here caused ranges from another site to
# overwrite customer, address and price data on the wrong connection.
return _normalize_service_address_for_match(row.get("address")) == _normalize_service_address_for_match(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()
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,
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
AND connection.deleted_at IS NULL
ORDER BY range.id
""",
(cidr,),
) or []
if reference:
matching_reference = [
row for row in rows
if _normalize_provider_reference(row.get("provider_reference")) == reference
]
if matching_reference:
rows = matching_reference
if not rows:
return {"connection_id": None, "conflict_reason": None}
normalized_target = _normalize_service_address_for_match(service_address)
exact_address = [
row for row in rows
if normalized_target
and _normalize_service_address_for_match(row.get("service_address") or row.get("connection_address")) == normalized_target
]
if len(exact_address) == 1:
return {
"connection_id": int(exact_address[0]["connection_id"]),
"canonical_reference": exact_address[0].get("connection_reference"),
"conflict_reason": None,
}
if len(rows) == 1 and not normalized_target:
return {
"connection_id": int(rows[0]["connection_id"]),
"canonical_reference": rows[0].get("connection_reference"),
"conflict_reason": None,
}
known_addresses = sorted({
str(row.get("service_address") or row.get("connection_address") or "").strip()
for row in rows
if str(row.get("service_address") or row.get("connection_address") or "").strip()
})
reason = f"CIDR {cidr} findes allerede"
if known_addresses:
reason += f" på anden adresse: {', '.join(known_addresses)}"
return {"connection_id": None, "conflict_reason": reason}
def _connection_skip_reason(line: Dict) -> str:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
if not reference:
@ -1575,6 +1680,48 @@ def _build_sync_audit_entry(line: Dict, classification: str, status: str, reason
}
def _clear_inherited_addresses_for_addressless_eb_ranges(lines: List[Dict]) -> List[Dict]:
"""
GlobalConnect's IP overview does not repeat a service address for legacy
EB references. The extractor can incorrectly carry the preceding NKA
address forward. If the matching existing EB connection is deliberately
addressless, retain that unknown state instead of trusting the inherited
address.
"""
references_by_address: Dict[str, set] = {}
for line in lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
address = _normalize_service_address_for_match(_build_service_address(line))
if _looks_like_ip_range_line(line) and reference.startswith("EB") and address:
references_by_address.setdefault(address, set()).add(reference)
# A repeated address across several unrelated EB circuits is the extractor
# carrying the preceding site's address forward. Explicit EB addresses are
# retained when they occur on a single circuit.
inherited_addresses = {
address for address, references in references_by_address.items()
if len(references) >= 2
}
sanitized = []
for source_line in lines:
line = dict(source_line)
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
normalized_address = _normalize_service_address_for_match(_build_service_address(line))
if (
_looks_like_ip_range_line(line)
and reference.startswith("EB")
and normalized_address in inherited_addresses
):
line["service_address"] = None
line["location_street"] = None
line["location_zip"] = None
line["location_city"] = None
line["address_source"] = "not_stated_on_invoice"
sanitized.append(line)
return sanitized
def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_range_candidates: int) -> Dict:
actionable_lines = [entry for entry in line_audit if entry["classification"] in {"connection", "ip_range"}]
synced_lines = [entry for entry in actionable_lines if entry["status"] == "synced"]
@ -1594,11 +1741,13 @@ def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_ran
}
def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: bool = False) -> Dict:
def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simulate: bool = False) -> Dict:
if not _is_globalconnect_extraction(extraction_row):
return {"skipped": True, "reason": "not_globalconnect"}
lines = _load_extraction_lines(extraction_row)
lines = _clear_inherited_addresses_for_addressless_eb_ranges(
_load_extraction_lines(extraction_row)
)
if not lines:
return {"skipped": True, "reason": "no_lines"}
@ -1680,8 +1829,16 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b
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)
connection_id = connection_map.get(reference)
existing_range_resolution = _resolve_existing_ip_range_connection(line)
if existing_range_resolution.get("conflict_reason"):
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)
resolved_from_existing = False
if existing_range_resolution.get("connection_id"):
resolved_from_existing = True
if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
connection_id = None
if not connection_id and reference:
@ -1700,9 +1857,15 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b
line_audit[audit_index]["reason"] = connection_conflict_reason
continue
sync_line = dict(line)
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"]
line_audit[audit_index]["matched_by"] = "existing_cidr_and_address"
line_audit[audit_index]["canonical_reference"] = existing_range_resolution["canonical_reference"]
if connection_id and (
simulate
or _upsert_globalconnect_ip_range(connection_id, line, invoice_number)
or _upsert_globalconnect_ip_range(connection_id, sync_line, invoice_number)
):
created_or_updated_ranges += 1
if resolved_from_existing:
@ -1737,6 +1900,119 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b
}
def _record_internet_invoice_sync_run(
extraction_row: Dict,
*,
status: str,
result: Optional[Dict] = None,
error_message: Optional[str] = None,
) -> None:
"""Persist the outcome without allowing audit logging to break invoice processing."""
try:
extraction_id = extraction_row.get("extraction_id")
supplier_invoice = execute_query_single(
"""
SELECT id
FROM supplier_invoices
WHERE extraction_id = %s
ORDER BY id DESC
LIMIT 1
""",
(extraction_id,),
) if extraction_id else None
payload = result or {}
verification = payload.get("verification") or {}
execute_update(
"""
INSERT INTO internet_connections_invoice_sync_runs (
file_id, extraction_id, supplier_invoice_id, invoice_number, vendor_name,
invoice_date, status, connections_synced, connections_created,
connections_updated, ip_ranges_synced, total_lines, actionable_lines,
skipped_lines, error_message, result_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
extraction_row.get("file_id"),
extraction_id,
supplier_invoice.get("id") if supplier_invoice else None,
extraction_row.get("document_id") or extraction_row.get("invoice_number"),
extraction_row.get("vendor_name"),
extraction_row.get("document_date"),
status,
int(payload.get("connections_synced") or 0),
int(payload.get("connections_created") or 0),
int(payload.get("connections_updated") or 0),
int(payload.get("ip_ranges_synced") or 0),
int(verification.get("total_lines") or 0),
int(verification.get("actionable_lines") or 0),
int(verification.get("skipped_actionable_lines") or 0),
error_message,
json.dumps(payload, default=str),
),
)
except Exception as audit_error:
logger.warning("Could not persist internet invoice sync audit: %s", audit_error)
def _sync_globalconnect_extraction_to_internet(
extraction_row: Dict,
simulate: bool = False,
force: bool = False,
) -> Dict:
"""Run GlobalConnect sync and keep a permanent, queryable result for the overview."""
try:
if not simulate and not force:
invoice_number = str(
extraction_row.get("document_id") or extraction_row.get("invoice_number") or ""
).strip()
extraction_id = extraction_row.get("extraction_id")
previous_run = execute_query_single(
"""
SELECT id, status, processed_at
FROM internet_connections_invoice_sync_runs
WHERE status IN ('success', 'warning')
AND (
(%s IS NOT NULL AND extraction_id = %s)
OR (NULLIF(%s, '') IS NOT NULL AND invoice_number = %s)
)
ORDER BY processed_at DESC, id DESC
LIMIT 1
""",
(extraction_id, extraction_id, invoice_number, invoice_number),
)
if previous_run:
return {
"skipped": True,
"reason": "invoice_already_processed",
"invoice_number": invoice_number,
"previous_run_id": previous_run.get("id"),
"previous_status": previous_run.get("status"),
"previous_processed_at": previous_run.get("processed_at"),
}
result = _sync_globalconnect_extraction_to_internet_impl(extraction_row, simulate=simulate)
if not simulate:
verification = result.get("verification") or {}
if result.get("skipped"):
status = "skipped"
elif verification.get("requires_manual_review"):
status = "warning"
else:
status = "success"
_record_internet_invoice_sync_run(extraction_row, status=status, result=result)
return result
except Exception as exc:
if not simulate:
_record_internet_invoice_sync_run(
extraction_row,
status="error",
error_message=str(exc),
result={"exception_type": type(exc).__name__},
)
raise
def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]:
sku_value = str(sku or "").strip()
desc_value = str(description or "").strip()
@ -3078,7 +3354,7 @@ async def sync_extraction_to_internet(file_id: int):
if not extraction:
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
result = _sync_globalconnect_extraction_to_internet(extraction)
result = _sync_globalconnect_extraction_to_internet(extraction, force=True)
return {
"status": "success",
"file_id": file_id,
@ -4812,6 +5088,17 @@ async def reprocess_uploaded_file(file_id: int):
(vendor_id,))
if vendor:
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"
# GlobalConnect invoices must update the internet module immediately after
# extraction. Previously this only happened after a separate manual
# conversion to supplier_invoice, leaving valid extracted invoices queued.
if "extraction_id" in locals() and extraction_id:
latest_extraction = execute_query_single(
"SELECT * FROM extractions WHERE extraction_id = %s",
(extraction_id,),
)
if latest_extraction and _is_globalconnect_extraction(latest_extraction):
result["internet_sync"] = _sync_globalconnect_extraction_to_internet(latest_extraction)
return result

View File

@ -1,6 +1,6 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Leverandør fakturaer - BMC Hub{% endblock %}
{% block title %}Leverandørfakturaer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
@ -206,7 +206,7 @@
<ul class="nav nav-tabs mb-4" id="mainTabs">
<li class="nav-item">
<a class="nav-link active" id="unhandled-tab" data-bs-toggle="tab" href="#unhandled-content" onclick="switchToUnhandledTab()">
<i class="bi bi-inbox me-2"></i>Ubehandlede Fakturaer
<i class="bi bi-inbox me-2"></i>Ubehandlede fakturaer
<span class="badge bg-warning text-dark ms-2" id="unhandledCount" style="display: none;">0</span>
</a>
</li>
@ -241,7 +241,7 @@
<div class="alert alert-info mb-4">
<i class="bi bi-inbox me-2"></i>
<strong>Ubehandlede Fakturaer:</strong> PDFer der venter på analyse og vendor-matching. Klik "Analyser alle" for at køre automatisk extraction.
<strong>Ubehandlede fakturaer:</strong> PDF-filer, der venter på analyse og leverandørmatch. Klik på "Analyser alle" for at køre automatisk udtrækning.
</div>
<!-- Batch Actions Bar -->
@ -349,7 +349,7 @@
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i>
<strong>Til Betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug checkboxes til at vælge hvilke der skal betales.
<strong>Til betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug afkrydsningsfelterne til at vælge, hvilke der skal betales.
</div>
<!-- Bulk Actions Bar for Payment -->
@ -559,7 +559,7 @@
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-success" onclick="bulkCreateInvoices()" title="Opret fakturaer">
<i class="bi bi-plus-circle me-1"></i>Opret Fakturaer
<i class="bi bi-plus-circle me-1"></i>Opret fakturaer
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="bulkReprocess()" title="Genbehandle filer">
<i class="bi bi-arrow-clockwise me-1"></i>Genbehandle

View File

@ -6,7 +6,14 @@ Only GET endpoints for now
from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
from app.core.database import (
execute_query,
execute_insert,
execute_query_single,
get_db_connection,
release_db_connection,
)
from psycopg2.extras import RealDictCursor
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
@ -17,6 +24,7 @@ from app.customers.backend.router import (
SubscriptionComment,
)
import logging
import json
logger = logging.getLogger(__name__)
router = APIRouter()
@ -51,6 +59,26 @@ class ContactUpdate(BaseModel):
is_active: Optional[bool] = None
class ContactMergeRequest(BaseModel):
source_contact_id: int = Field(..., gt=0)
CONTACT_MERGE_RELATIONS = (
("Firmaer", "contact_companies", "contact_id"),
("Sager", "sag_kontakter", "contact_id"),
("Opkald", "telefoni_opkald", "kontakt_id"),
("SMS", "sms_messages", "kontakt_id"),
("E-mails", "tticket_email_metadata", "matched_contact_id"),
("Tickets", "tticket_tickets", "contact_id"),
("Ticketrelationer", "tticket_contacts", "contact_id"),
("AnyDesk-sessioner", "anydesk_sessions", "contact_id"),
("Forsendelser", "fedex_shipments", "contact_id"),
("Hardware", "hardware_contacts", "contact_id"),
("Salgsmuligheder", "pipeline_opportunity_contacts", "contact_id"),
("Lokationer", "locations_contacts", "related_contact_id"),
)
class ContactCompanyLink(BaseModel):
customer_id: int
is_primary: bool = True
@ -290,6 +318,162 @@ async def create_contact(contact: ContactCreate):
raise HTTPException(status_code=500, detail=str(e))
def _contact_merge_counts(contact_id: int) -> list[dict]:
counts = []
for label, table, column in CONTACT_MERGE_RELATIONS:
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
conversation_row = execute_query_single(
"""
SELECT COUNT(DISTINCT conversation.id)::int AS count
FROM conversations conversation
JOIN contact_companies cc ON cc.customer_id = conversation.customer_id
WHERE cc.contact_id = %s
""",
(contact_id,),
) or {}
counts.append({
"key": "conversations_via_company",
"label": "Samtaler via firma",
"count": int(conversation_row.get("count") or 0),
"preserved_via": "company",
})
return counts
@router.get("/contacts/{contact_id}/merge-preview")
async def preview_contact_merge(contact_id: int, source_contact_id: int = Query(..., gt=0)):
if contact_id == source_contact_id:
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
target = execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
source = execute_query_single("SELECT * FROM contacts WHERE id = %s", (source_contact_id,))
if not target or not source:
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
relations = _contact_merge_counts(source_contact_id)
return {
"target": target,
"source": source,
"relations": relations,
"total_relations": sum(item["count"] for item in relations),
}
@router.post("/contacts/{contact_id}/merge")
async def merge_contact(contact_id: int, request: ContactMergeRequest):
source_id = int(request.source_contact_id)
if contact_id == source_id:
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT * FROM contacts WHERE id IN (%s, %s) FOR UPDATE", (contact_id, source_id))
rows = cursor.fetchall()
by_id = {int(row["id"]): dict(row) for row in rows}
target = by_id.get(contact_id)
source = by_id.get(source_id)
if not target or not source:
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
moved = {}
# Preserve missing master data on the target contact.
merge_fields = ("first_name", "last_name", "email", "phone", "mobile", "title", "department", "user_company")
assignments = []
values = []
for field in merge_fields:
target_value = target.get(field)
source_value = source.get(field)
if (target_value is None or str(target_value).strip() == "") and source_value not in (None, ""):
assignments.append(f"{field} = %s")
values.append(source_value)
if assignments:
values.append(contact_id)
cursor.execute(f"UPDATE contacts SET {', '.join(assignments)}, updated_at = NOW() WHERE id = %s", tuple(values))
cursor.execute(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
SELECT %s, customer_id, is_primary, role, notes
FROM contact_companies WHERE contact_id = %s
ON CONFLICT (contact_id, customer_id) DO UPDATE SET
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
role = COALESCE(contact_companies.role, EXCLUDED.role),
notes = COALESCE(contact_companies.notes, EXCLUDED.notes)
""",
(contact_id, source_id),
)
cursor.execute("SELECT COUNT(*)::int AS count FROM contact_companies WHERE contact_id = %s", (source_id,))
moved["contact_companies"] = int(cursor.fetchone()["count"] or 0)
cursor.execute("DELETE FROM contact_companies WHERE contact_id = %s", (source_id,))
unique_relations = (
("hardware_contacts", "contact_id", "hardware_id", "TRUE", "TRUE"),
("pipeline_opportunity_contacts", "contact_id", "opportunity_id", "TRUE", "TRUE"),
("tticket_contacts", "contact_id", "ticket_id", "TRUE", "TRUE"),
("locations_contacts", "related_contact_id", "location_id", "src.deleted_at IS NULL", "dst.deleted_at IS NULL"),
)
for table, column, owner_column, source_clause, target_clause in unique_relations:
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
moved[table] = int(cursor.fetchone()["count"] or 0)
cursor.execute(
f"DELETE FROM {table} src WHERE src.{column} = %s AND {source_clause} "
f"AND EXISTS (SELECT 1 FROM {table} dst WHERE dst.{column} = %s "
f"AND dst.{owner_column} = src.{owner_column} AND {target_clause})",
(source_id, contact_id),
)
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
# Avoid duplicate active case-contact rows, while retaining roles and history.
cursor.execute("SELECT COUNT(*)::int AS count FROM sag_kontakter WHERE contact_id = %s", (source_id,))
moved["sag_kontakter"] = int(cursor.fetchone()["count"] or 0)
cursor.execute(
"""
DELETE FROM sag_kontakter src
WHERE src.contact_id = %s AND src.deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM sag_kontakter dst
WHERE dst.contact_id = %s AND dst.sag_id = src.sag_id AND dst.deleted_at IS NULL
)
""",
(source_id, contact_id),
)
cursor.execute("UPDATE sag_kontakter SET contact_id = %s WHERE contact_id = %s", (contact_id, source_id))
direct_relations = (
("telefoni_opkald", "kontakt_id"),
("sms_messages", "kontakt_id"),
("tticket_email_metadata", "matched_contact_id"),
("tticket_tickets", "contact_id"),
("anydesk_sessions", "contact_id"),
("fedex_shipments", "contact_id"),
)
for table, column in direct_relations:
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
moved[table] = int(cursor.fetchone()["count"] or 0)
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
cursor.execute(
"""
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
VALUES (%s, %s, %s::jsonb, %s::jsonb)
""",
(contact_id, source_id, json.dumps(source, default=str), json.dumps(moved)),
)
cursor.execute("DELETE FROM contacts WHERE id = %s", (source_id,))
conn.commit()
return {"success": True, "target_contact_id": contact_id, "merged_contact_id": source_id, "moved_relations": moved}
except HTTPException:
conn.rollback()
raise
except Exception as exc:
conn.rollback()
logger.error("Failed merging contact %s into %s: %s", source_id, contact_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Kontakterne kunne ikke flettes. Ingen ændringer blev gemt.")
finally:
release_db_connection(conn)
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies"""
@ -846,12 +1030,34 @@ async def get_contact_kontakt_history(contact_id: int, limit: int = Query(defaul
FROM sms_messages s
LEFT JOIN users u ON u.user_id = s.bruger_id
WHERE s.kontakt_id = %s
UNION ALL
SELECT
'merge' AS type,
h.id::text AS event_id,
h.merged_at AS happened_at,
NULL::text AS direction,
NULL::text AS number,
CONCAT(
'Flettet med ',
COALESCE(NULLIF(TRIM(CONCAT(
h.source_snapshot->>'first_name', ' ',
h.source_snapshot->>'last_name'
)), ''), 'kontakt #' || h.source_contact_id::text),
' (#', h.source_contact_id::text, ')'
) AS message,
NULL::int AS duration_sec,
NULL::text AS user_name,
'completed'::text AS sms_status
FROM contact_merge_history h
WHERE h.target_contact_id = %s
) z
ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (contact_id, contact_id, limit)) or []
rows = execute_query(query, (contact_id, contact_id, contact_id, limit)) or []
return {"items": rows}
except HTTPException:
raise

View File

@ -134,6 +134,9 @@
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light btn-sm" onclick="openMergeContactModal()" title="Flet en dubletkontakt ind i denne kontakt">
<i class="bi bi-intersect me-2"></i>Flet dublet
</button>
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('contact', contactId)" title="Opret vigtig information/advarsel om denne kontakt">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
</button>
@ -705,6 +708,48 @@
</div>
</div>
<!-- Merge duplicate contact modal -->
<div class="modal fade" id="mergeContactModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title"><i class="bi bi-intersect me-2"></i>Flet dubletkontakt</h5>
<div class="small text-muted">Denne kontakt beholdes som hovedkontakt. Den valgte dublet slettes efter flytning.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-primary py-2">
<strong>Hovedkontakt:</strong> <span id="mergeTargetName">-</span>
</div>
<label class="form-label fw-semibold" for="mergeContactSearch">Find dublet</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input id="mergeContactSearch" class="form-control" type="search" placeholder="Søg på navn, e-mail, telefon eller firma" autocomplete="off">
</div>
<div id="mergeContactResults" class="list-group mb-3"></div>
<div id="mergeContactPreview" class="d-none">
<div class="border rounded p-3">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><div class="small text-muted">Dublet der fjernes</div><strong id="mergeSourceName"></strong><div class="small text-muted" id="mergeSourceDetails"></div></div>
<span class="badge text-bg-warning">Kan ikke fortrydes direkte</span>
</div>
<div class="small fw-semibold mb-2">Relateret information der flyttes</div>
<div id="mergeRelationCounts" class="d-flex flex-wrap gap-2"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button id="mergeContactSubmit" type="button" class="btn btn-danger" onclick="submitContactMerge()" disabled>
<i class="bi bi-intersect me-2"></i>Flet kontakter
</button>
</div>
</div>
</div>
</div>
<!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@ -802,10 +847,20 @@ let contactData = null;
let primaryCustomerId = null;
let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
document.addEventListener('DOMContentLoaded', () => {
loadContact();
loadCompaniesForSelect();
const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) {
mergeSearch.addEventListener('input', () => {
clearTimeout(mergeSearchTimer);
mergeSearchTimer = setTimeout(() => searchMergeContacts(mergeSearch.value), 250);
});
}
// Load companies when tab is shown
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
@ -1882,7 +1937,7 @@ function renderKontaktHistoryTable() {
if (!filteredItems.length) {
const msg = kontaktHistoryItems.length
? 'Ingen hændelser matcher filteret'
: 'Ingen opkald eller SMS fundet';
: 'Ingen kontakthistorik fundet';
container.innerHTML = `<div class="text-muted text-center py-5">${msg}</div>`;
return;
}
@ -1913,13 +1968,17 @@ function renderKontaktHistoryRow(item) {
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
const typeBadge = item.type === 'sms'
? '<span class="badge bg-primary-subtle text-primary-emphasis">SMS</span>'
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
: item.type === 'merge'
? '<span class="badge bg-info-subtle text-info-emphasis">Fletning</span>'
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-')
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
: item.type === 'merge'
? 'Gennemført'
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
const message = item.type === 'sms'
const message = item.type === 'sms' || item.type === 'merge'
? escapeHtml(item.message || '-')
: '-';
@ -2327,6 +2386,106 @@ async function saveEditContact() {
}
}
function contactMergeName(contact) {
if (!contact) return 'Ukendt kontakt';
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|| contact.email
|| `Kontakt #${contact.id}`;
}
function openMergeContactModal() {
mergeSourceContactId = null;
const targetName = contactMergeName(contactData);
const automaticSearch = `${contactData?.first_name || ''} ${contactData?.last_name || ''}`.trim();
document.getElementById('mergeTargetName').textContent = targetName;
document.getElementById('mergeContactSearch').value = automaticSearch;
document.getElementById('mergeContactResults').innerHTML = '<div class="text-muted small">Søger efter mulige dubletter…</div>';
document.getElementById('mergeContactPreview').classList.add('d-none');
document.getElementById('mergeContactSubmit').disabled = true;
const modalElement = document.getElementById('mergeContactModal');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
modalElement.addEventListener('shown.bs.modal', () => document.getElementById('mergeContactSearch').focus(), { once: true });
if (automaticSearch.length >= 2) {
searchMergeContacts(automaticSearch);
}
}
async function searchMergeContacts(query) {
const results = document.getElementById('mergeContactResults');
const value = query.trim();
if (value.length < 2) {
results.innerHTML = '<div class="text-muted small">Skriv mindst 2 tegn.</div>';
return;
}
results.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger…</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(value)}&limit=20`);
if (!response.ok) throw new Error('Søgningen fejlede');
const data = await response.json();
const contacts = (data.contacts || []).filter(contact => Number(contact.id) !== contactId);
if (!contacts.length) {
results.innerHTML = '<div class="text-muted small">Ingen andre kontakter fundet.</div>';
return;
}
results.innerHTML = contacts.map(contact => {
const companies = (contact.company_names || []).join(', ') || 'Intet firma';
const details = [contact.email, contact.mobile || contact.phone, companies].filter(Boolean).join(' · ');
return `<button type="button" class="list-group-item list-group-item-action" onclick="selectMergeContact(${Number(contact.id)})">
<div class="fw-semibold">${escapeHtml(contactMergeName(contact))}</div>
<div class="small text-muted">#${Number(contact.id)} · ${escapeHtml(details)}</div>
</button>`;
}).join('');
} catch (error) {
results.innerHTML = `<div class="text-danger small">${escapeHtml(error.message)}</div>`;
}
}
async function selectMergeContact(sourceId) {
try {
const response = await fetch(`/api/v1/contacts/${contactId}/merge-preview?source_contact_id=${sourceId}`);
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente fletteoversigt');
mergeSourceContactId = sourceId;
document.getElementById('mergeSourceName').textContent = contactMergeName(data.source);
document.getElementById('mergeSourceDetails').textContent = [
`Kontakt #${data.source.id}`,
data.source.email,
data.source.mobile || data.source.phone,
].filter(Boolean).join(' · ');
const relevant = (data.relations || []).filter(item => Number(item.count) > 0);
document.getElementById('mergeRelationCounts').innerHTML = relevant.length
? relevant.map(item => `<span class="badge text-bg-light border">${escapeHtml(item.label)}: ${Number(item.count)}</span>`).join('')
: '<span class="text-muted small">Ingen direkte relationer fundet.</span>';
document.getElementById('mergeContactPreview').classList.remove('d-none');
document.getElementById('mergeContactSubmit').disabled = false;
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
async function submitContactMerge() {
if (!mergeSourceContactId) return;
const sourceName = document.getElementById('mergeSourceName').textContent;
if (!confirm(`Flet ${sourceName} ind i ${contactMergeName(contactData)}?\n\nSager, firmaer, e-mails, opkald og øvrige relationer flyttes. Dubletkontakten slettes.`)) return;
const button = document.getElementById('mergeContactSubmit');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_contact_id: mergeSourceContactId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kontakterne kunne ikke flettes');
bootstrap.Modal.getInstance(document.getElementById('mergeContactModal'))?.hide();
alert('Kontakterne er flettet. Alle relationer er samlet på hovedkontakten.');
window.location.reload();
} catch (error) {
button.disabled = false;
alert(`Fejl: ${error.message}`);
}
}
function getInitials(firstName, lastName) {
if (!firstName && !lastName) return '?';
const first = firstName ? firstName[0] : '';

View File

@ -20,6 +20,7 @@ class Settings(BaseSettings):
API_PORT: int = 8000
API_RELOAD: bool = False
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
@ -68,6 +69,12 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: bool = True
ECONOMIC_DRY_RUN: bool = True
# Manual migration centre
MIGRATION_CENTER_READ_ONLY: bool = False
MIGRATION_CENTER_STALE_AFTER_DAYS: int = 7
MIGRATION_CENTER_VTIGER_LOCK_FIELD: str = ""
MIGRATION_CENTER_SIMPLY_LOCK_FIELD: str = ""
# Nextcloud Integration
NEXTCLOUD_READ_ONLY: bool = True
NEXTCLOUD_DRY_RUN: bool = True

View File

@ -135,6 +135,19 @@ class CustomerUpdate(BaseModel):
department: Optional[str] = None
class EmailDomainCreate(BaseModel):
domain: str
def _normalize_email_domain(value: str) -> str:
domain = str(value or "").strip().lower()
domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.")
domain = domain.split("/", 1)[0].strip(". ")
if not domain or "." not in domain or "@" in domain:
raise HTTPException(status_code=400, detail="Ugyldigt emaildomæne")
return domain
class ContactCreate(BaseModel):
first_name: str
last_name: str
@ -461,6 +474,59 @@ async def verify_customer_linking():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/email-domains")
async def list_customer_email_domains(customer_id: int):
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
rows = execute_query(
"""
SELECT domain, created_at
FROM email_domain_customer_mappings
WHERE customer_id = %s
ORDER BY domain
""",
(customer_id,),
) or []
return rows
@router.post("/customers/{customer_id}/email-domains")
async def add_customer_email_domain(customer_id: int, payload: EmailDomainCreate):
if not execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)):
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
domain = _normalize_email_domain(payload.domain)
existing = execute_query_single(
"SELECT customer_id FROM email_domain_customer_mappings WHERE domain = %s",
(domain,),
)
if existing and int(existing["customer_id"]) != customer_id:
raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden kunde")
return execute_query_single(
"""
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
VALUES (%s, %s, 'manual_customer')
ON CONFLICT (domain) DO UPDATE SET
customer_id = EXCLUDED.customer_id,
source = EXCLUDED.source
RETURNING domain, customer_id, created_at
""",
(domain, customer_id),
)
@router.delete("/customers/{customer_id}/email-domains/{domain}")
async def delete_customer_email_domain(customer_id: int, domain: str):
normalized = _normalize_email_domain(domain)
deleted = execute_update(
"DELETE FROM email_domain_customer_mappings WHERE customer_id = %s AND domain = %s",
(customer_id, normalized),
)
if not deleted:
raise HTTPException(status_code=404, detail="Domænet blev ikke fundet")
return {"success": True}
@router.get("/customers/{customer_id}")
async def get_customer(customer_id: int):
"""Get single customer by ID with contact count and vTiger BMC Låst status"""

View File

@ -1000,6 +1000,16 @@
<span class="info-value" id="wikiLink">-</span>
</div>
</div>
<div class="info-card mt-4">
<h5 class="fw-bold mb-2">Emaildomæner</h5>
<p class="small text-muted">Kun eksakte domæner bruges til automatisk match.</p>
<div class="input-group input-group-sm mb-3">
<input id="customerEmailDomainInput" class="form-control" placeholder="fx firma.dk">
<button class="btn btn-primary" onclick="addCustomerEmailDomain()">Tilføj</button>
</div>
<div id="customerEmailDomains" class="d-flex flex-wrap gap-2"></div>
</div>
</div>
<div class="col-lg-6">
@ -1033,18 +1043,6 @@
<span class="info-label">Standard timepris</span>
<span class="info-value" id="standardHourlyRate">-</span>
</div>
<div class="info-row">
<span class="info-label">Særlig fragtpris</span>
<span class="info-value" id="specialFreightPrice">-</span>
</div>
<div class="info-row">
<span class="info-label">Leverandørservice</span>
<span class="info-value" id="supplierServiceEnrolled">-</span>
</div>
<div class="info-row">
<span class="info-label">Faktureringsgebyr</span>
<span class="info-value" id="invoiceFeeAmount">-</span>
</div>
<div class="info-row">
<span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span>
@ -1973,25 +1971,6 @@
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
</div>
<div class="col-md-6">
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
</div>
<div class="col-md-6">
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
<label class="form-check-label" for="editSupplierServiceEnrolled">
Tilmeldt leverandørservice
</label>
</div>
</div>
<!-- Status -->
<div class="col-12 mt-4">
@ -2468,6 +2447,7 @@ async function loadCustomer() {
customerData = await response.json();
displayCustomer(customerData);
await loadCustomerEmailDomains();
await loadUtilityCompany();
await loadCustomerTags();
@ -2482,6 +2462,52 @@ async function loadCustomer() {
}
}
async function loadCustomerEmailDomains() {
const host = document.getElementById('customerEmailDomains');
if (!host) return;
try {
const response = await fetch(`/api/v1/customers/${customerId}/email-domains`);
if (!response.ok) throw new Error('Kunne ikke hente domæner');
const rows = await response.json();
host.innerHTML = rows.length ? rows.map(row => `
<span class="badge bg-light text-dark border p-2">
${escapeHtml(row.domain)}
<button class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="deleteCustomerEmailDomain('${encodeURIComponent(row.domain)}')" title="Fjern">×</button>
</span>
`).join('') : '<span class="small text-muted">Ingen domæner registreret.</span>';
} catch (error) {
host.innerHTML = `<span class="small text-danger">${escapeHtml(error.message)}</span>`;
}
}
async function addCustomerEmailDomain() {
const input = document.getElementById('customerEmailDomainInput');
const domain = input.value.trim();
if (!domain) return;
const response = await fetch(`/api/v1/customers/${customerId}/email-domains`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke tilføjes');
return;
}
input.value = '';
await loadCustomerEmailDomains();
}
async function deleteCustomerEmailDomain(encodedDomain) {
const response = await fetch(`/api/v1/customers/${customerId}/email-domains/${encodedDomain}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke fjernes');
return;
}
await loadCustomerEmailDomains();
}
async function loadCustomerVendorLinks() {
const container = document.getElementById('customerVendorLinksContainer');
const empty = document.getElementById('customerVendorLinksEmpty');
@ -2697,21 +2723,10 @@ function displayCustomer(customer) {
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
document.getElementById('ean').textContent = customer.ean || '-';
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
const freight = customer.special_freight_price;
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
? '-'
: `${Number(freight).toFixed(2)} DKK`;
document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
? '<span class="badge bg-success">Tilmeldt</span>'
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
? '0,00 DKK (deaktiveret)'
: `${Number(invoiceFee).toFixed(2)} DKK`;
document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>';
@ -5503,9 +5518,6 @@ function editCustomer() {
document.getElementById('editCity').value = customerData.city || '';
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
document.getElementById('editIsActive').checked = customerData.is_active !== false;
// Show modal
@ -5516,9 +5528,6 @@ function editCustomer() {
async function saveCustomerEdit() {
const marginValue = document.getElementById('editStandardMarginPercent').value;
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
const freightValue = document.getElementById('editSpecialFreightPrice').value;
const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
const updateData = {
name: document.getElementById('editName').value,
cvr_number: document.getElementById('editCvrNumber').value || null,
@ -5534,9 +5543,6 @@ async function saveCustomerEdit() {
city: document.getElementById('editCity').value || null,
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue),
special_freight_price: freightValue === '' ? null : Number(freightValue),
supplier_service_enrolled: document.getElementById('editSupplierServiceEnrolled').checked,
invoice_fee_amount: invoiceFeeValue === '' ? customerDefaultInvoiceFee : Number(invoiceFeeValue),
is_active: document.getElementById('editIsActive').checked
};

View File

@ -121,6 +121,10 @@ class MissionProjectLinkCasePayload(BaseModel):
project_task_type: Optional[str] = None
class MissionCallCaseLinkPayload(BaseModel):
sag_id: int = Field(..., gt=0)
def _first_query_param(request: Request, *names: str) -> Optional[str]:
for name in names:
value = request.query_params.get(name)
@ -351,6 +355,66 @@ async def get_mission_state():
return MissionService.get_state()
@router.get("/mission/calls/history")
async def get_mission_call_history(limit: int = Query(100, ge=1, le=500)):
return {"calls": MissionService.get_call_history(limit=limit)}
@router.get("/mission/cases/search")
async def search_mission_cases(q: str = Query("", max_length=120), limit: int = Query(20, ge=1, le=50)):
needle = str(q or "").strip()
like = f"%{needle}%"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.status,
c.name AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND (
%s = ''
OR s.id::text = %s
OR s.titel ILIKE %s
OR c.name ILIKE %s
)
ORDER BY
CASE WHEN s.id::text = %s THEN 0 ELSE 1 END,
s.updated_at DESC NULLS LAST,
s.id DESC
LIMIT %s
""",
(needle, needle, like, like, needle, limit),
) or []
return {"cases": rows}
@router.patch("/mission/calls/{call_id}/case")
async def link_mission_call_to_case(call_id: int, request: Request, payload: MissionCallCaseLinkPayload):
_require_authenticated_user(request)
case = execute_query_single(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(payload.sag_id,),
)
if not case:
raise HTTPException(status_code=404, detail="Sag ikke fundet")
rows = execute_query(
"""
UPDATE telefoni_opkald
SET sag_id = %s
WHERE id = %s
RETURNING id, callid, sag_id
""",
(payload.sag_id, call_id),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Opkald ikke fundet eller kan ikke redigeres")
return {**dict(rows[0]), "sag_titel": case.get("titel")}
@router.get("/mission/projects")
async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
return {

View File

@ -224,6 +224,52 @@ class MissionService:
)
return rows or []
@staticmethod
def get_call_history(limit: int = 100) -> list[Dict[str, Any]]:
if not MissionService._table_exists("telefoni_opkald"):
return []
rows = execute_query(
"""
SELECT
t.id,
t.callid,
t.direction,
t.ekstern_nummer AS display_number,
t.started_at,
t.ended_at,
COALESCE(
t.duration_sec,
CASE
WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
END
) AS duration_sec,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS employee_name,
NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name,
customer.name AS company_name,
t.sag_id,
s.titel AS sag_titel,
s.status AS sag_status
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
LEFT JOIN LATERAL (
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) customer ON TRUE
LEFT JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL
ORDER BY t.started_at DESC, t.id DESC
LIMIT %s
""",
(limit,),
) or []
return [dict(row) for row in rows]
@staticmethod
def get_active_alerts() -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_uptime_alerts"):
@ -412,6 +458,13 @@ class MissionService:
0 AS score,
s.start_date AS started_at,
s.deadline AS ended_at,
COALESCE(NULLIF(TRIM(c.name), ''), 'Ingen kunde') AS customer_name,
COALESCE(
NULLIF(TRIM(u.full_name), ''),
NULLIF(TRIM(u.username), ''),
CASE WHEN s.ansvarlig_bruger_id IS NOT NULL THEN CONCAT('Bruger #', s.ansvarlig_bruger_id::text) END
) AS responsible_name,
next_todo.title AS next_step,
s.created_at AS updated_at,
0 AS active_milestones,
0 AS overdue_milestones,
@ -426,6 +479,17 @@ class MissionService:
ELSE 0
END AS overdue_tasks
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN LATERAL (
SELECT t.title
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
ORDER BY COALESCE(t.is_next, FALSE) DESC, t.due_date ASC NULLS LAST, t.id ASC
LIMIT 1
) next_todo ON TRUE
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
AND (
@ -448,8 +512,15 @@ class MissionService:
@staticmethod
def get_projects(limit: int = 120) -> list[Dict[str, Any]]:
# Project cases are the canonical project portfolio shown in Mission Control.
# `mission_projects` is retained as a legacy fallback for installations that
# do not yet have case-backed projects.
case_projects = MissionService._get_projects_from_cases(limit)
if case_projects:
return case_projects
if not MissionService._table_exists("mission_projects"):
return MissionService._get_projects_from_cases(limit)
return []
rows = execute_query(
"""
@ -462,6 +533,12 @@ class MissionService:
p.started_at,
p.ended_at,
p.updated_at,
COALESCE(
NULLIF(TRIM(owner.full_name), ''),
NULLIF(TRIM(owner.username), ''),
task_owner.responsible_name
) AS responsible_name,
COALESCE(next_milestone.title, next_task.next_step) AS next_step,
COUNT(DISTINCT m.id) FILTER (
WHERE m.status NOT IN ('completed', 'cancelled')
) AS active_milestones,
@ -490,10 +567,49 @@ class MissionService:
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
) AS overdue_tasks
FROM mission_projects p
LEFT JOIN users owner ON owner.user_id = p.created_by
LEFT JOIN LATERAL (
SELECT COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name
FROM sag_sager s_owner
JOIN users u ON u.user_id = s_owner.ansvarlig_bruger_id
WHERE s_owner.project_id = p.id
AND s_owner.deleted_at IS NULL
AND LOWER(COALESCE(s_owner.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
ORDER BY s_owner.deadline ASC NULLS LAST, s_owner.id ASC
LIMIT 1
) task_owner ON TRUE
LEFT JOIN LATERAL (
SELECT mm.title
FROM mission_project_milestones mm
WHERE mm.project_id = p.id
AND mm.status NOT IN ('completed', 'cancelled')
ORDER BY mm.target_date ASC NULLS LAST, mm.id ASC
LIMIT 1
) next_milestone ON TRUE
LEFT JOIN LATERAL (
SELECT COALESCE(todo.title, s_next.titel) AS next_step
FROM sag_sager s_next
LEFT JOIN LATERAL (
SELECT t.title
FROM sag_todo_steps t
WHERE t.sag_id = s_next.id
AND t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
ORDER BY COALESCE(t.is_next, FALSE) DESC, t.due_date ASC NULLS LAST, t.id ASC
LIMIT 1
) todo ON TRUE
WHERE s_next.project_id = p.id
AND s_next.deleted_at IS NULL
AND LOWER(COALESCE(s_next.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
ORDER BY s_next.deadline ASC NULLS LAST, s_next.id ASC
LIMIT 1
) next_task ON TRUE
LEFT JOIN mission_project_milestones m ON m.project_id = p.id
LEFT JOIN mission_project_blockers b ON b.project_id = p.id
LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL
GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at
GROUP BY
p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at,
owner.full_name, owner.username, task_owner.responsible_name, next_milestone.title, next_task.next_step
ORDER BY p.updated_at DESC, p.id DESC
LIMIT %s
""",
@ -506,14 +622,11 @@ class MissionService:
item.update(MissionService._compute_project_risk(item))
result.append(item)
# Important fallback: migration may have created mission_projects but with zero rows.
if not result:
return MissionService._get_projects_from_cases(limit)
return result
@staticmethod
def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]:
if not MissionService._table_exists("mission_projects"):
if not MissionService._table_exists("sag_sager"):
return None
rows = MissionService.get_projects(limit=200)
@ -556,15 +669,19 @@ class MissionService:
s.titel,
s.status,
s.priority,
s.start_date,
s.deadline,
s.ansvarlig_bruger_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
s.project_milestone_id,
s.is_project_blocker,
s.project_task_type,
s.created_at,
COALESCE(ts.open_todo_count, 0) AS open_todo_count,
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles,
COALESCE(ts.open_todos, '[]'::jsonb) AS open_todos
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (
@ -582,6 +699,24 @@ class MissionService:
),
NULL
) AS open_todo_titles
,
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', t.id,
'title', t.title,
'due_date', t.due_date,
'is_next', COALESCE(t.is_next, FALSE)
)
ORDER BY COALESCE(t.is_next, FALSE) DESC,
COALESCE(t.due_date, DATE '9999-12-31') ASC,
t.id ASC
) FILTER (
WHERE t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
),
'[]'::jsonb
) AS open_todos
FROM sag_todo_steps t
WHERE t.sag_id = s.id
) ts ON TRUE
@ -626,8 +761,8 @@ class MissionService:
if isinstance(titles_raw, list):
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
# Case-backed projects store their actual subcases as directed `undersag`
# relations. Other relation types must not appear as project subcases.
if not tasks and MissionService._table_exists("sag_relationer"):
tasks = execute_query(
"""
@ -638,31 +773,27 @@ class MissionService:
FROM sag_relationer sr
WHERE sr.deleted_at IS NULL
AND sr.kilde_sag_id = %s
UNION ALL
SELECT
sr.kilde_sag_id AS task_id,
sr.relationstype AS relation_type
FROM sag_relationer sr
WHERE sr.deleted_at IS NULL
AND sr.målsag_id = %s
AND LOWER(TRIM(sr.relationstype)) IN ('undersag', 'barn')
)
SELECT
s.id,
s.titel,
s.status,
s.priority,
s.start_date,
s.deadline,
s.ansvarlig_bruger_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
s.project_milestone_id,
s.is_project_blocker,
COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
s.created_at,
COALESCE(ts.open_todo_count, 0) AS open_todo_count,
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles,
COALESCE(ts.open_todos, '[]'::jsonb) AS open_todos
FROM related r
JOIN sag_sager s ON s.id = r.task_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (
@ -679,7 +810,24 @@ class MissionService:
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
),
NULL
) AS open_todo_titles
) AS open_todo_titles,
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', t.id,
'title', t.title,
'due_date', t.due_date,
'is_next', COALESCE(t.is_next, FALSE)
)
ORDER BY COALESCE(t.is_next, FALSE) DESC,
COALESCE(t.due_date, DATE '9999-12-31') ASC,
t.id ASC
) FILTER (
WHERE t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
),
'[]'::jsonb
) AS open_todos
FROM sag_todo_steps t
WHERE t.sag_id = s.id
) ts ON TRUE
@ -690,7 +838,7 @@ class MissionService:
s.created_at DESC
LIMIT 200
""",
(project_id, project_id, project_id),
(project_id, project_id),
) or []
return {

View File

@ -189,12 +189,21 @@ async def search_sag(q: str):
s.beskrivelse,
s.status,
s.created_at,
(s.deleted_at IS NOT NULL) AS is_archived,
s.customer_id,
c.name as customer_name
c.name as customer_name,
ARRAY(
SELECT b.word
FROM sag_buzzwords sb
JOIN buzzwords b ON b.id = sb.buzzword_id
WHERE sb.sag_id = s.id
AND sb.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY b.word
) AS buzzwords
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND (
WHERE (
CAST(s.id AS TEXT) ILIKE %s OR
s.titel ILIKE %s OR
s.beskrivelse ILIKE %s OR

View File

@ -965,7 +965,7 @@
<script>
(() => {
const kpiLabels = {
open_cases: 'Aabne sager',
open_cases: 'Åbne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
@ -1362,7 +1362,7 @@
}
const call = state.activeCalls[0];
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = [];
if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name);
@ -1381,7 +1381,7 @@
list.innerHTML = state.activeCalls.map((call) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
@ -1736,7 +1736,7 @@
function renderMotionBadge() {
if (state.cameraMotion && state.cameraMotion.motion) {
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');

View File

@ -965,7 +965,7 @@
<script>
(() => {
const kpiLabels = {
open_cases: 'Aabne sager',
open_cases: 'Åbne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
@ -1362,7 +1362,7 @@
}
const call = state.activeCalls[0];
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = [];
if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name);
@ -1381,7 +1381,7 @@
list.innerHTML = state.activeCalls.map((call) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
@ -1736,7 +1736,7 @@
function renderMotionBadge() {
if (state.cameraMotion && state.cameraMotion.motion) {
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');

File diff suppressed because it is too large Load Diff

View File

@ -795,6 +795,11 @@
<button class="btn btn-sm btn-outline-secondary js-product-map-toggle" data-line-id="${line.id}" type="button">
${state.activeProductMapLineId === Number(line.id) ? 'Luk produkt' : 'Ret produkt'}
</button>
${!line.matched_product_id ? `
<button class="btn btn-sm btn-primary js-product-create ms-1" data-line-id="${line.id}" type="button">
<i class="bi bi-plus-circle me-1"></i>Opret produkt i Hub
</button>
` : ''}
</div>
${state.activeProductMapLineId === Number(line.id) ? renderProductMapEditor(line) : ''}
` : ''}
@ -908,6 +913,24 @@
}
};
const createProductForLine = async (lineId) => {
const line = (state.selectedJobLines || []).find(item => Number(item.id) === Number(lineId));
const productName = line?.product_name || 'dette produkt';
if (!window.confirm(`Opret ${productName} som produkt i Hub og map ALSO-varen automatisk?`)) return;
el('uploadResult').textContent = `Opretter Hub-produkt for linje #${lineId}...`;
const result = await fetchJson(`/api/v1/also/import-lines/${lineId}/create-product`, {
method: 'POST',
});
el('uploadResult').textContent = [
`${result.product_created ? 'Hub-produkt oprettet og mappet' : 'Eksisterende Hub-produkt mappet'}: ${result.product_name} (#${result.product_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n');
await refreshAll();
if (state.selectedJobId) await loadJobDetails(state.selectedJobId);
};
const autoMapSelectedJob = async () => {
if (!state.selectedJobId) return;
el('uploadResult').textContent = `Kører auto-map for job #${state.selectedJobId}...`;
@ -1140,6 +1163,25 @@
return;
}
const createProductButton = event.target.closest('.js-product-create');
if (createProductButton) {
const lineId = Number(createProductButton.getAttribute('data-line-id'));
const originalHtml = createProductButton.innerHTML;
createProductButton.disabled = true;
createProductButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter...';
try {
await createProductForLine(lineId);
} catch (err) {
console.error(err);
el('uploadResult').textContent = `Produktoprettelse fejlede:\n${err.message}`;
alert(`Kunne ikke oprette produktet.\n\n${err.message}`);
} finally {
createProductButton.disabled = false;
createProductButton.innerHTML = originalHtml;
}
return;
}
const row = event.target.closest('[data-job-row]');
if (!row) return;
await loadJobDetails(Number(row.getAttribute('data-job-row')));

View File

@ -447,7 +447,7 @@
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overførsel til e-conomic)');
if (!ok) return;
try {
@ -462,7 +462,7 @@
const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
const nextStep = result.orders_url ? `\n\nÅbn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`

View File

@ -9,15 +9,15 @@ from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime, date
import unicodedata
import html
import re
from html.parser import HTMLParser
from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService
from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier
logger = logging.getLogger(__name__)
@ -130,84 +130,8 @@ def _is_supplier_case_type(case_type: Optional[str]) -> bool:
return "indk" in value or "leverand" in value or "supplier" in value
class _SafeDescriptionHtmlSanitizer(HTMLParser):
"""Allow-list HTML sanitizer for case descriptions created from email content."""
_ALLOWED_TAGS = {
"b", "strong", "i", "em", "u",
"p", "br", "hr",
"ul", "ol", "li",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
"a",
}
_VOID_TAGS = {"br", "hr"}
_ALLOWED_ATTRS = {
"a": {"href", "title", "target", "rel"},
"th": {"colspan", "rowspan"},
"td": {"colspan", "rowspan"},
}
def __init__(self):
super().__init__(convert_charrefs=True)
self._parts: List[str] = []
def handle_starttag(self, tag, attrs):
tag = (tag or "").lower()
if tag not in self._ALLOWED_TAGS:
return
attr_allow = self._ALLOWED_ATTRS.get(tag, set())
safe_attrs = []
for key, value in attrs or []:
key_l = str(key or "").lower()
if key_l not in attr_allow:
continue
safe_value = str(value or "").strip()
if key_l == "href":
href_l = safe_value.lower()
if href_l.startswith(("javascript:", "data:", "vbscript:")):
continue
safe_attrs.append(f'{key_l}="{html.escape(safe_value, quote=True)}"')
attrs_html = (" " + " ".join(safe_attrs)) if safe_attrs else ""
self._parts.append(f"<{tag}{attrs_html}>")
def handle_endtag(self, tag):
tag = (tag or "").lower()
if tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
return
self._parts.append(f"</{tag}>")
def handle_data(self, data):
self._parts.append(html.escape(data or ""))
def handle_entityref(self, name):
self._parts.append(f"&{name};")
def handle_charref(self, name):
self._parts.append(f"&#{name};")
def get_html(self) -> str:
return "".join(self._parts).strip()
def _sanitize_case_description_html(value: Optional[str]) -> str:
raw = str(value or "").strip()
if not raw:
return ""
# Fast path: plain text stays plain text.
if "<" not in raw and ">" not in raw:
return raw
sanitizer = _SafeDescriptionHtmlSanitizer()
try:
sanitizer.feed(raw)
sanitizer.close()
return sanitizer.get_html()
except Exception:
# Fallback to escaped text if parsing fails.
return html.escape(raw)
return sanitize_safe_html(value)
def _extract_domain_from_email(email: Optional[str]) -> str:
@ -465,6 +389,11 @@ class CreateSagFromEmailRequest(BaseModel):
relation_type: str = "mail"
class EmailQuickActionRequest(BaseModel):
action: str
titel: Optional[str] = None
class EmailReadStateUpdate(BaseModel):
is_read: bool
@ -582,6 +511,11 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
steps = wf.get('workflow_steps')
steps_total = len(steps) if isinstance(steps, list) else 0
actions = [
str(step.get("action") or "").strip()
for step in (steps or [])
if isinstance(step, dict) and str(step.get("action") or "").strip()
]
row = {
'id': wf.get('id'),
'name': wf.get('name'),
@ -592,12 +526,17 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'sender_pattern': sender_pattern,
'subject_pattern': subject_pattern,
'steps_total': steps_total,
'actions': actions,
'matches': bool(matches),
'reasons': reasons,
}
candidates.append(row)
if matches:
matching.append(row)
# Preview must mirror execution: a completed stop-on-match workflow
# prevents lower-priority workflows from running.
if row["stop_on_match"]:
break
system_matches = []
if classification == 'bankruptcy':
@ -606,12 +545,16 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'name': 'System: Bankruptcy Analysis',
'matches': True,
'reason': 'classification == bankruptcy',
'effect': 'Ved eksakt CVR-match oprettes en sag og en kritisk Alert Note på kunden',
'automatic': True,
})
has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
supplier_document = classification in {'invoice', 'order_confirmation', 'freight_note'}
hard_skip = {'newsletter', 'spam'}
should_try_helpdesk = (
classification not in hard_skip
not supplier_document
and classification not in hard_skip
and (
classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS
or has_hint
@ -622,6 +565,15 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'name': 'System: Helpdesk SAG routing',
'matches': bool(should_try_helpdesk),
'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list',
'effect': (
'Forsøger at finde en eksisterende sag eller oprette en sag for en kendt kunde'
if should_try_helpdesk
else (
'Ingen supportsag oprettes dokumentet behandles som leverandørbilag'
if supplier_document
else 'Ingen sag oprettes eller tilknyttes automatisk'
)
),
})
return {
@ -636,7 +588,60 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'matching_workflows': matching,
'workflow_candidates': candidates,
'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)),
'automatic_execution': classification == 'bankruptcy',
}
def _find_existing_vendor_for_email(email_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Match only trusted exact identifiers. Company-name guessing is forbidden."""
extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip()
sender_email = str(email_data.get("sender_email") or "").strip().lower()
sender_domain = sender_email.rsplit("@", 1)[1] if "@" in sender_email else ""
if extracted_cvr:
match = execute_query_single(
"""
SELECT id, name, cvr_number, domain, email, 100 AS match_score,
'exact_cvr'::text AS source
FROM vendors
WHERE is_active = true
AND TRIM(COALESCE(cvr_number, '')) = %s
ORDER BY id ASC LIMIT 1
""",
(extracted_cvr,),
)
if match:
return match
if sender_email:
match = execute_query_single(
"""
SELECT id, name, cvr_number, domain, email, 100 AS match_score,
'exact_email'::text AS source
FROM vendors
WHERE is_active = true
AND LOWER(TRIM(COALESCE(email, ''))) = %s
ORDER BY id ASC LIMIT 1
""",
(sender_email,),
)
if match:
return match
if not sender_domain:
return None
return execute_query_single(
"""
SELECT v.id, v.name, v.cvr_number, v.domain, v.email,
100 AS match_score, 'exact_domain'::text AS source
FROM vendor_email_domains d
JOIN vendors v ON v.id = d.vendor_id
WHERE v.is_active = true AND LOWER(TRIM(d.domain)) = %s
LIMIT 1
""",
(sender_domain,),
)
context: Optional[str] = None
@ -859,35 +864,6 @@ async def get_domain_customer_suggestion(email_id: int):
},
}
partial = execute_query_single(
"""
SELECT id, name, email_domain, cvr_number
FROM customers
WHERE is_active = true
AND COALESCE(email_domain, '') ILIKE %s
ORDER BY name ASC
LIMIT 1
""",
(f"%{sender_domain}%",),
)
if partial:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": partial["id"],
"customer_name": partial["name"],
"email_domain": partial.get("email_domain"),
"cvr_number": partial.get("cvr_number"),
"confidence": "medium",
"score": 70,
"source": "partial_domain",
},
}
return {
"email_id": email_id,
"domain": sender_domain,
@ -1329,15 +1305,22 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
requested_case_type = _normalize_case_type(payload.case_type)
customer_id = payload.customer_id or email_data.get('customer_id')
if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
# A vendor is not a customer. Supplier-originated cases are owned by
# the internal procurement customer, while the email keeps only its
# supplier_id relation.
if not customer_id and email_data.get('supplier_id'):
customer_id = _resolve_procurement_customer_id()
if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _resolve_procurement_customer_id()
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
if not email_data.get('customer_id') and customer_id:
if (
not _is_supplier_case_type(requested_case_type)
and not email_data.get('customer_id')
and customer_id
):
execute_update(
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(customer_id, email_id),
@ -1345,7 +1328,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
sender_domain = _extract_sender_domain(email_data.get("sender_email"))
if sender_domain and not _is_ignored_sender_domain(sender_domain):
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
_upsert_domain_mapping(sender_domain, int(customer_id), "case_creation")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
@ -1454,6 +1437,121 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/quick-action")
async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest):
"""Run one explicit, identity-safe action from the email decision panel."""
action = str(payload.action or "").strip().lower()
allowed_actions = {
"create_customer_case",
"create_supplier_invoice",
"create_accounting_case",
"archive_entity_mail",
}
if action not in allowed_actions:
raise HTTPException(status_code=400, detail="Ukendt emailhandling")
email_data = execute_query_single(
"""
SELECT id, subject, sender_email, body_text, body_html, classification,
confidence_score, customer_id, supplier_id, linked_case_id
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
""",
(email_id,),
)
if not email_data:
raise HTTPException(status_code=404, detail="Email blev ikke fundet")
customer_id = email_data.get("customer_id")
supplier_id = email_data.get("supplier_id")
title = str(payload.titel or email_data.get("subject") or "Sag fra email").strip()
if action in {"create_customer_case", "create_accounting_case"}:
if not customer_id:
raise HTTPException(
status_code=409,
detail="Fastslå og tilknyt kunden, før du opretter en kundesag",
)
group_id = None
case_type = "support"
if action == "create_accounting_case":
group = execute_query_single(
"""
SELECT id
FROM groups
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id
LIMIT 1
"""
)
if not group:
raise HTTPException(
status_code=409,
detail="Bogholderi-gruppen mangler i Indstillinger → Grupper",
)
group_id = int(group["id"])
case_type = "bogholderi"
return await create_sag_from_email(
email_id,
CreateSagFromEmailRequest(
titel=title,
customer_id=int(customer_id),
case_type=case_type,
assigned_group_id=group_id,
),
)
if action == "create_supplier_invoice":
if not supplier_id:
raise HTTPException(
status_code=409,
detail="Fastslå og tilknyt leverandøren, før fakturaen behandles",
)
# This button is an explicit user decision and therefore authoritative.
email_data["classification"] = "invoice"
email_data["confidence_score"] = 1.0
execute_update(
"""
UPDATE email_messages
SET classification = 'invoice', confidence_score = 1.0,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(email_id,),
)
result = await email_workflow_service.execute_workflows(email_data)
return {
"success": True,
"action": action,
"result": result,
"message": "Leverandørfaktura sendt til behandling",
}
if not customer_id and not supplier_id:
raise HTTPException(
status_code=409,
detail="Fastslå kunde eller leverandør, før mailen arkiveres",
)
execute_update(
"""
UPDATE email_messages
SET status = 'archived', folder = 'Archive', updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(email_id,),
)
return {
"success": True,
"action": action,
"message": "Mailen er arkiveret under den fastsatte kunde eller leverandør",
}
@router.post("/emails/{email_id}/link-sag")
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
"""Link an email to an existing SAG and optionally append a system note."""
@ -2317,12 +2415,29 @@ async def upload_emails(files: List[UploadFile] = File(...)):
except Exception as e:
logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
# Execute workflows
try:
logger.info(f"⚙️ Executing workflows for email {email_id}...")
await email_workflow_service.execute_workflows_for_email(email_id)
except Exception as e:
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
# Manual uploads follow the same approval policy as mailbox imports:
# classify and preview now; execute only after explicit user approval.
if classification == "bankruptcy":
try:
logger.info("⚙️ Kører automatisk konkursflow for email %s", email_id)
await email_workflow_service.execute_workflows_for_email(email_id)
except Exception as e:
logger.warning("⚠️ Automatisk konkursflow fejlede for email %s: %s", email_id, e)
elif getattr(settings, "EMAIL_REQUIRE_MANUAL_APPROVAL", True):
execute_update(
"""
UPDATE email_messages
SET status = 'awaiting_user_action'
WHERE id = %s
""",
(email_id,),
)
else:
try:
logger.info(f"⚙️ Executing workflows for email {email_id}...")
await email_workflow_service.execute_workflows_for_email(email_id)
except Exception as e:
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
results.append({
"filename": file.filename,
@ -2856,7 +2971,7 @@ async def execute_workflows_for_email(email_id: int):
query = """
SELECT id, message_id, subject, sender_email, sender_name, body_text,
body_html, in_reply_to, email_references, thread_key,
classification, confidence_score, status
classification, confidence_score, status, customer_id, supplier_id
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
"""
@ -2866,6 +2981,22 @@ async def execute_workflows_for_email(email_id: int):
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_result[0] # Get first row as dict
if not str(email_data.get("classification") or "").strip():
classified = simple_classifier.classify(email_data)
email_data["classification"] = classified["classification"]
email_data["confidence_score"] = classified["confidence"]
execute_update(
"""
UPDATE email_messages
SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
classified["classification"],
classified["confidence"],
email_id,
),
)
# Execute workflows
result = await email_workflow_service.execute_workflows(email_data)
@ -2884,11 +3015,16 @@ async def preview_workflows_for_email(email_id: int):
"""Preview which workflows would match an email without executing them."""
try:
query = """
SELECT id, message_id, subject, sender_email, sender_name, body_text,
body_html, in_reply_to, email_references, thread_key,
classification, confidence_score, status
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
SELECT em.id, em.message_id, em.subject, em.sender_email, em.sender_name,
em.body_text, em.body_html, em.in_reply_to, em.email_references,
em.thread_key, em.classification, em.confidence_score, em.status,
em.customer_id, em.supplier_id, em.extracted_vendor_name,
em.extracted_vendor_cvr, c.name AS customer_name,
v.name AS supplier_name, v.cvr_number AS supplier_cvr
FROM email_messages em
LEFT JOIN customers c ON c.id = em.customer_id
LEFT JOIN vendors v ON v.id = em.supplier_id
WHERE em.id = %s AND em.deleted_at IS NULL
"""
email_result = execute_query(query, (email_id,))
@ -2896,7 +3032,80 @@ async def preview_workflows_for_email(email_id: int):
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_result[0]
return _compute_workflow_preview(email_data)
if not str(email_data.get("classification") or "").strip():
classified = simple_classifier.classify(email_data)
email_data["classification"] = classified["classification"]
email_data["confidence_score"] = classified["confidence"]
execute_update(
"""
UPDATE email_messages
SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
classified["classification"],
classified["confidence"],
email_id,
),
)
preview = _compute_workflow_preview(email_data)
supplier_document = str(email_data.get("classification") or "").lower() in {
"invoice",
"order_confirmation",
"freight_note",
}
customer_identity = (
{
"has_customer": False,
"not_applicable": True,
"reason": "supplier_document",
"domain": _extract_sender_domain(email_data.get("sender_email")),
"suggestion": None,
}
if supplier_document
else await get_domain_customer_suggestion(email_id)
)
vendor_identity = {
"has_vendor": bool(email_data.get("supplier_id")),
"suggestion": None,
}
if email_data.get("supplier_id"):
vendor_identity["suggestion"] = {
"vendor_id": email_data.get("supplier_id"),
"name": email_data.get("supplier_name"),
"cvr_number": email_data.get("supplier_cvr"),
"match_score": 100,
"source": "linked_email",
}
else:
extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip()
extracted_name = str(email_data.get("extracted_vendor_name") or "").strip()
matched_vendor = _find_existing_vendor_for_email(email_data)
if matched_vendor:
vendor_identity["suggestion"] = {
"vendor_id": matched_vendor.get("id"),
"name": matched_vendor.get("name"),
"cvr_number": matched_vendor.get("cvr_number"),
"domain": matched_vendor.get("domain"),
"email": matched_vendor.get("email"),
"match_score": matched_vendor.get("match_score"),
"source": matched_vendor.get("source"),
}
elif extracted_name or extracted_cvr:
vendor_identity["suggestion"] = {
"vendor_id": None,
"name": extracted_name or None,
"cvr_number": extracted_cvr or None,
"match_score": 0,
"source": "extracted_email_data",
}
preview["identity"] = {
"customer": customer_identity,
"vendor": vendor_identity,
}
return preview
except HTTPException:
raise

View File

@ -1008,14 +1008,19 @@
/* Email Upload Drop Zone */
.email-upload-zone {
padding: 0.75rem 1rem;
padding: 0.4rem 0.65rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.drop-zone {
border: 2px dashed var(--accent);
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.25rem 0.45rem;
border: 1px dashed var(--accent);
border-radius: var(--border-radius);
padding: 1.5rem 1rem;
padding: 0.48rem 0.65rem;
text-align: center;
background: rgba(15, 76, 117, 0.03);
cursor: pointer;
@ -1036,15 +1041,24 @@
}
.drop-zone i {
font-size: 2rem;
font-size: 1.05rem;
color: var(--accent);
display: block;
margin-bottom: 0.5rem;
display: inline-block;
margin: 0;
}
.drop-zone p {
margin: 0;
font-size: 0.9rem;
font-size: 0.78rem;
}
.drop-zone small {
font-size: 0.68rem;
}
.drop-zone .d-block {
display: inline !important;
margin-top: 0 !important;
}
.upload-progress {
@ -1122,7 +1136,7 @@
</div>
<!-- Email Upload Drop Zone -->
<div class="email-upload-zone" id="emailUploadZone" style="display: none;">
<div class="email-upload-zone" id="emailUploadZone">
<div class="drop-zone" id="dropZone">
<i class="bi bi-cloud-upload"></i>
<p class="mb-1"><strong>Træk emails hertil</strong></p>
@ -1137,11 +1151,6 @@
<small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small>
</div>
</div>
<div class="p-2 border-bottom">
<button class="btn btn-sm btn-outline-secondary w-100" onclick="toggleUploadZone()">
<i class="bi bi-upload me-1"></i> Upload Emails
</button>
</div>
<div class="email-list-filters">
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
@ -5278,16 +5287,6 @@ function showNotification(message, type = 'info') {
setTimeout(() => toast.remove(), 3000);
}
// Email Upload Functionality
function toggleUploadZone() {
const uploadZone = document.getElementById('emailUploadZone');
if (uploadZone.style.display === 'none') {
uploadZone.style.display = 'block';
} else {
uploadZone.style.display = 'none';
}
}
// Setup drag and drop
document.addEventListener('DOMContentLoaded', () => {
const dropZone = document.getElementById('dropZone');
@ -5398,7 +5397,6 @@ async function uploadEmailFiles(files) {
console.log('✅ Email list and stats reloaded');
uploadProgress.style.display = 'none';
fileInput.value = '';
toggleUploadZone(); // Close upload zone after successful upload
}, 2000);
} else {
console.log(' No new emails uploaded, not reloading');

View File

@ -1,6 +1,6 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Email v2 - BMC Hub{% endblock %}
{% block title %}Email - BMC Hub{% endblock %}
{% block extra_css %}
<style>
@ -47,6 +47,81 @@
border-bottom: 1px solid var(--border-color);
}
.emails-quick-upload {
margin: 0.65rem 0.9rem;
padding: 0.55rem 0.7rem;
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
border-radius: 9px;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
text-align: center;
transition: 0.15s ease;
}
.emails-quick-upload:hover,
.emails-quick-upload.dragover {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
}
.emails-quick-upload.busy {
pointer-events: none;
opacity: 0.65;
}
.emails-primary-action {
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
padding: 0.9rem;
margin-bottom: 0.85rem;
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.email-ai-type {
font-size: 1.15rem;
font-weight: 750;
}
.email-ai-confidence {
height: 7px;
border-radius: 99px;
overflow: hidden;
background: var(--border-color);
}
.email-ai-confidence > span {
display: block;
height: 100%;
background: var(--accent);
}
.email-ai-actions {
margin: 0;
padding-left: 1.2rem;
font-size: 0.86rem;
}
.email-ai-actions li {
margin-bottom: 0.32rem;
}
.emails-shortcuts {
font-size: 0.72rem;
color: var(--text-secondary);
}
.emails-shortcuts kbd {
font-size: 0.68rem;
}
.emails-v2-filters {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--border-color);
@ -161,6 +236,27 @@
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-quick-toolbar {
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
}
.emails-v2-quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.emails-v2-quick-grid .btn {
min-height: 38px;
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-mail-body {
flex: 1;
overflow: auto;
@ -357,19 +453,25 @@
<div class="emails-v2-shell">
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h1 class="emails-v2-title">Email v2</h1>
<div class="emails-v2-version-nav">
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Gå til v1</a>
<a href="/emails/v2" class="btn btn-sm btn-primary" aria-current="page">v2</a>
</div>
<h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
<button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="emails-v2-search">
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg afsender eller emne...">
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg i mails… (tryk /)">
</div>
<div id="v2Filters" class="emails-v2-filters"></div>
<div class="emails-quick-upload" id="v2UploadZone" role="button" tabindex="0">
<span id="v2UploadLabel">
<i class="bi bi-paperclip me-1"></i>Træk .eml/.msg hertil eller klik
</span>
<input id="v2UploadInput" type="file" accept=".eml,.msg" multiple hidden>
</div>
<div id="v2List" class="emails-v2-list"></div>
<div id="v2ListStatus" class="emails-v2-status">Klar</div>
@ -378,14 +480,13 @@
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h2 class="emails-v2-title">Detalje</h2>
<div class="d-flex gap-2">
<button id="v2FetchTest" class="btn btn-sm btn-outline-success">Hent fra test-mappe</button>
<button id="v2Refresh" class="btn btn-sm btn-outline-primary">Opdater</button>
</div>
<span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
</div>
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
<div id="v2QuickActions"></div>
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
@ -394,7 +495,7 @@
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h2 class="emails-v2-title">Handlinger</h2>
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Sammenlign v1</a>
<span class="small text-muted">Vælg hvad der skal ske</span>
</div>
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
@ -408,8 +509,6 @@
{% block extra_js %}
<script>
(() => {
const TEST_MAILBOX_FOLDER = 'BMC_TEST';
const FILTERS = [
{ key: 'active', label: 'Aktive' },
{ key: 'awaiting_user_action', label: 'Afventer handling' },
@ -424,7 +523,7 @@
workflowPreview: null,
filter: 'active',
query: '',
folder: TEST_MAILBOX_FOLDER,
folder: '',
searchTimer: null,
sagSearchTimer: null,
vendorSuggestion: null,
@ -660,20 +759,31 @@
const rows = await apiFetch(url);
let emails = Array.isArray(rows) ? rows : [];
if (state.filter === 'active' && !state.query) {
// Processed/archived emails must never reappear in the active
// inbox, including when the user searches.
if (state.filter === 'active') {
emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase()));
}
state.emails = emails;
renderFilters();
renderList();
setListStatus(`${emails.length} emails vist (mappe: ${state.folder || 'alle'})`);
setListStatus(`${emails.length} emails`);
if (state.selectedEmailId) {
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
if (stillExists) {
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
} else {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
}
}
} else if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
}
} catch (error) {
state.emails = [];
@ -781,6 +891,15 @@
try {
const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`);
state.workflowPreview = preview || null;
if (preview?.identity) {
state.domainCustomerSuggestion = preview.identity.customer || {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
state.vendorSuggestion = preview.identity.vendor?.suggestion || {};
renderDomainCustomerSuggestion();
renderVendorSuggestion();
}
renderWorkflowPreview();
} catch (error) {
state.workflowPreview = null;
@ -803,18 +922,236 @@
}
}
const CLASSIFICATION_LABELS = {
invoice: 'Leverandørfaktura',
freight_note: 'Fragtbrev',
order_confirmation: 'Ordrebekræftelse',
time_confirmation: 'Tidsbekræftelse',
case_notification: 'Sagsnotifikation',
customer_email: 'Kundehenvendelse',
bankruptcy: 'Konkursmeddelelse',
general: 'Almindelig email',
spam: 'Spam',
newsletter: 'Nyhedsbrev',
unknown: 'Ukendt type',
};
const WORKFLOW_ACTION_LABELS = {
create_ticket: 'Opretter en sag/opgave',
link_email_to_ticket: 'Knytter emailen til en eksisterende sag',
route_helpdesk_sag: 'Finder eller opretter den relevante sag',
create_time_entry: 'Opretter en tidsregistrering',
link_to_vendor: 'Knytter emailen til leverandøren',
link_to_customer: 'Knytter emailen til kunden',
extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling',
extract_tracking_number: 'Udtrækker trackingnummer',
regex_extract_and_link: 'Finder reference og opretter tilknytning',
process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder',
send_slack_notification: 'Sender en Slack-notifikation',
send_email_notification: 'Sender en emailnotifikation',
mark_as_processed: 'Markerer emailen som behandlet',
flag_for_review: 'Markerer emailen til manuel kontrol',
};
function renderIdentityAssessment() {
const email = state.selectedEmail || {};
const customerData = state.domainCustomerSuggestion;
const customerSuggestion = customerData?.suggestion;
const vendorSuggestion = state.vendorSuggestion;
const senderDomain = customerData?.domain
|| String(email.sender_email || '').split('@')[1]
|| '';
let customerHtml;
if (customerData?.not_applicable) {
customerHtml = `
<div class="fw-semibold">Ikke relevant</div>
<div class="small text-muted">Dette er et leverandørdokument. Afsenderen matches kun mod leverandører.</div>
`;
} else if (email.customer_id) {
customerHtml = `
<div class="fw-semibold text-success">${escapeHtml(email.customer_name || `Kunde #${email.customer_id}`)}</div>
<div class="small text-muted">Allerede tilknyttet denne email</div>
`;
} else if (customerSuggestion?.customer_id) {
customerHtml = `
<div class="fw-semibold">${escapeHtml(customerSuggestion.customer_name || `Kunde #${customerSuggestion.customer_id}`)}</div>
<div class="small text-muted">${escapeHtml(customerSuggestion.confidence || 'ukendt')} sikkerhed · ${escapeHtml(customerSuggestion.source || 'domænematch')}</div>
<button id="v2DecisionLinkCustomer" class="btn btn-sm btn-outline-primary mt-2">Tilknyt kunden</button>
`;
} else if (customerData) {
const proposedName = email.sender_name || senderDomain || 'Ny kunde';
customerHtml = `
<div class="fw-semibold">${escapeHtml(proposedName)}</div>
<div class="small text-muted">Ingen eksisterende kunde fundet${senderDomain ? ` for ${escapeHtml(senderDomain)}` : ''}</div>
<button id="v2DecisionCreateCustomer" class="btn btn-sm btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt kunde
</button>
`;
} else {
customerHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder kunde…</div>';
}
let vendorHtml;
if (email.supplier_id) {
vendorHtml = `
<div class="fw-semibold text-success">${escapeHtml(email.supplier_name || `Leverandør #${email.supplier_id}`)}</div>
<div class="small text-muted">Allerede tilknyttet denne email</div>
`;
} else if (vendorSuggestion?.vendor_id) {
vendorHtml = `
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name || `Leverandør #${vendorSuggestion.vendor_id}`)}</div>
<div class="small text-muted">Matchscore ${Number(vendorSuggestion.match_score || 0)}${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
<button id="v2DecisionLinkVendor" class="btn btn-sm btn-outline-primary mt-2">Tilknyt leverandøren</button>
`;
} else if (vendorSuggestion?.name) {
vendorHtml = `
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name)}</div>
<div class="small text-muted">Ny leverandør${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt leverandør
</button>
`;
} else if (vendorSuggestion) {
vendorHtml = `
<div class="small text-muted">Systemet fandt ikke en sikker leverandør.</div>
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-outline-primary mt-2">
Opret leverandør fra afsender
</button>
`;
} else {
vendorHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder leverandør…</div>';
}
return `
<div class="row g-2 mb-3">
<div class="col-12">
<div class="border rounded p-2">
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-building me-1"></i>Systemet tror kunden er</div>
${customerHtml}
</div>
</div>
<div class="col-12">
<div class="border rounded p-2">
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-truck me-1"></i>Systemet tror leverandøren er</div>
${vendorHtml}
</div>
</div>
</div>
`;
}
function bindIdentityDecisionActions() {
document.getElementById('v2DecisionLinkCustomer')?.addEventListener('click', applyDomainCustomerSuggestion);
document.getElementById('v2DecisionCreateCustomer')?.addEventListener('click', createCustomerFromSuggestion);
document.getElementById('v2DecisionLinkVendor')?.addEventListener('click', linkVendorSuggestion);
document.getElementById('v2DecisionCreateVendor')?.addEventListener('click', createVendorFromSuggestion);
}
function renderClassificationDecision(errorMessage) {
const host = document.getElementById('v2DecisionCard');
if (!host) return;
if (errorMessage) {
host.innerHTML = `
<div class="fw-semibold text-danger">Vurdering kunne ikke indlæses</div>
<div class="small text-muted mt-1">${escapeHtml(errorMessage)}</div>
`;
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Systemet vurderer mailen…</div>';
return;
}
const meta = preview.email || {};
const classification = String(meta.classification || 'unknown').toLowerCase();
const typeLabel = CLASSIFICATION_LABELS[classification] || classification;
const rawConfidence = Number(meta.confidence_score || 0);
const confidencePercent = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100);
const confidenceLabel = confidencePercent >= 80
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const effects = [];
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
(preview.matching_workflows || []).forEach((workflow) => {
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
actions.forEach((action) => effects.push(
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<div class="d-flex justify-content-between small mt-2 mb-1">
<span>${escapeHtml(confidenceLabel)}</span>
<strong>${confidencePercent}%</strong>
</div>
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-3">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : `
<button id="v2ApproveActions" class="btn btn-success w-100">
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
</button>`}
` : `
<div class="alert alert-secondary py-2 small mb-0">
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
</div>
`}
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
</div>
`;
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
bindIdentityDecisionActions();
}
async function approveSuggestedActions() {
const button = document.getElementById('v2ApproveActions');
if (!button || !state.selectedEmailId) return;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
await executeWorkflowsCurrent();
} finally {
if (document.body.contains(button)) {
button.disabled = false;
button.innerHTML = '<i class="bi bi-check-circle me-2"></i>Godkend og udfør';
}
}
}
function renderWorkflowPreview(errorMessage) {
const host = document.getElementById('v2WorkflowPreview');
if (!host) return;
if (errorMessage) {
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
renderClassificationDecision(errorMessage);
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
renderClassificationDecision();
return;
}
@ -856,6 +1193,7 @@
${matchingHtml}
</div>
`;
renderClassificationDecision();
const autoRunBtn = document.getElementById('v2AutoRun');
if (autoRunBtn) {
@ -884,24 +1222,12 @@
}
}
async function fetchFromTestFolder() {
try {
setDetailStatus('Henter nye emails fra test-mappe...');
await apiFetch(`/api/v1/emails/process?folder=${encodeURIComponent(state.folder)}&limit=50`, {
method: 'POST',
});
await loadEmails();
setDetailStatus(`Import fuldført fra ${state.folder}`);
} catch (error) {
setDetailStatus(`Kunne ikke hente fra test-mappe: ${error.message}`);
}
}
async function createCaseFromCurrent() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const caseTypeEl = document.getElementById('v2CaseType');
const titelEl = document.getElementById('v2CaseTitle');
const createButton = document.getElementById('v2CreateCase');
const payload = {
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
case_type: String(caseTypeEl?.value || 'support'),
@ -912,6 +1238,10 @@
}
try {
if (createButton) {
createButton.disabled = true;
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
}
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -923,6 +1253,49 @@
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
window.alert(`Sagen kunne ikke oprettes:\n${error.message}`);
} finally {
if (createButton && document.body.contains(createButton)) {
createButton.disabled = false;
createButton.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Opret';
}
}
}
async function runQuickAction(action, button) {
if (!state.selectedEmailId || !state.selectedEmail || !action) return;
const originalHtml = button?.innerHTML || '';
try {
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Udfører…';
}
const title = String(document.getElementById('v2CaseTitle')?.value
|| state.selectedEmail.subject
|| '').trim();
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/quick-action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, titel: title }),
});
if (action === 'archive_entity_mail') {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
await loadEmails();
} else {
await selectEmail(state.selectedEmailId);
}
setDetailStatus(result?.message || 'Handlingen er udført');
} catch (error) {
setDetailStatus(`Handlingen fejlede: ${error.message}`);
window.alert(`Handlingen kunne ikke udføres:\n${error.message}`);
} finally {
if (button && document.body.contains(button)) {
button.disabled = false;
button.innerHTML = originalHtml;
}
}
}
@ -978,8 +1351,11 @@
});
state.vendorSuggestion = suggestion || null;
renderVendorSuggestion();
renderClassificationDecision();
setSupplierStatus('Forslag opdateret', 'success');
} catch (error) {
state.vendorSuggestion = {};
renderClassificationDecision();
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
}
}
@ -1034,8 +1410,14 @@
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
state.domainCustomerSuggestion = suggestion || null;
renderDomainCustomerSuggestion();
renderClassificationDecision();
setDomainStatus('Forslag opdateret', 'success');
} catch (error) {
state.domainCustomerSuggestion = {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
renderClassificationDecision();
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
}
}
@ -1059,6 +1441,98 @@
}
}
async function createCustomerFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const domain = state.domainCustomerSuggestion?.domain
|| String(email.sender_email || '').split('@')[1]
|| null;
const name = String(email.sender_name || domain || '').trim();
if (!name) {
setDetailStatus('Kunden kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter kunden ${name}…`);
const customer = await apiFetch('/api/v1/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email: email.sender_email || null,
email_domain: domain,
is_active: true,
country: 'DK',
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: Number(customer.id) }),
});
setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`);
}
}
async function linkVendorSuggestion() {
const vendorId = Number(state.vendorSuggestion?.vendor_id || 0);
if (!state.selectedEmailId || !vendorId) return;
try {
setDetailStatus('Tilknytter leverandøren…');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: vendorId }),
});
setDetailStatus('Leverandøren blev tilknyttet');
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`);
}
}
async function createVendorFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const suggestion = state.vendorSuggestion || {};
const senderDomain = String(email.sender_email || '').split('@')[1] || null;
const name = String(suggestion.name || email.sender_name || senderDomain || '').trim();
if (!name) {
setDetailStatus('Leverandøren kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter leverandøren ${name}…`);
const vendor = await apiFetch('/api/v1/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
cvr_number: suggestion.cvr_number || null,
email: suggestion.email || email.sender_email || null,
phone: suggestion.phone || null,
address: suggestion.address || null,
domain: suggestion.domain || senderDomain,
category: 'supplier',
notes: `Oprettet fra email #${state.selectedEmailId}`,
is_active: true,
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: Number(vendor.id) }),
});
setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`);
}
}
async function searchSager(query) {
const host = document.getElementById('v2SagResults');
if (!host) return;
@ -1097,12 +1571,14 @@
function renderDetail(email) {
const mailHeader = document.getElementById('v2MailHeader');
const quickActions = document.getElementById('v2QuickActions');
const mailBody = document.getElementById('v2MailBody');
const sideActions = document.getElementById('v2SideActions');
if (!mailHeader || !mailBody || !sideActions) return;
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
if (!email) {
mailHeader.innerHTML = 'Vælg en email for at se info';
quickActions.innerHTML = '';
mailBody.className = 'emails-v2-detail-empty';
mailBody.textContent = 'Vælg en email fra listen';
sideActions.className = 'emails-v2-detail-empty';
@ -1125,6 +1601,31 @@
</div>
`;
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier;
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
<i class="bi bi-person-workspace me-1"></i>Kundesag
</button>
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
</button>
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
</button>
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
<i class="bi bi-archive me-1"></i>Arkivér mail
</button>
</div>
`;
mailBody.className = 'emails-v2-mail-body';
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
const rawHtml = String(email.body_html || '').trim();
@ -1153,53 +1654,76 @@
sideActions.className = 'emails-v2-actions-pane';
sideActions.innerHTML = `
<div class="emails-v2-card">
<h6>Hurtighandlinger</h6>
<div class="emails-v2-actions">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}</button>
<button id="v2Archive" class="btn btn-sm btn-outline-primary">Arkivér</button>
<button id="v2Processed" class="btn btn-sm btn-outline-success">Markér behandlet</button>
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
<button id="v2AutoParse" class="btn btn-sm btn-primary">Auto parse tråd/sag</button>
<div id="v2DecisionCard" class="email-ai-decision">
<div class="d-flex align-items-center gap-2 text-muted">
<span class="spinner-border spinner-border-sm"></span>
Systemet vurderer mailtype og handlinger…
</div>
</div>
<div class="emails-v2-card">
<h6>Workflow match-preview</h6>
<div id="v2WorkflowPreview"><div class="small text-muted">Henter preview...</div></div>
</div>
${email.linked_case_id ? `
<div class="emails-v2-card emails-primary-action">
<h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
<button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button>
</div>` : `
<div class="emails-v2-card emails-primary-action">
<h6>Opret sag fra denne email</h6>
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
<option value="support" ${!email.supplier_id ? 'selected' : ''}>Support</option>
<option value="bogholderi">Bogholderi</option>
<option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave">Projekt/helhedsopgave</option>
<option value="andet">Andet</option>
</select>
<button id="v2CreateCase" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div>
</div>`}
<div class="emails-v2-card">
<h6>Link til eksisterende sag</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Søg sag-ID, titel eller beskrivelse...">
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
<div id="v2SagResults" class="emails-v2-sag-results"></div>
</div>
<div class="emails-v2-card">
<h6>Opret ny sag fra email</h6>
<div class="row g-2">
<div class="col-12">
<input id="v2CaseTitle" class="form-control form-control-sm" value="${escapeHtml(email.subject || '')}" placeholder="Sags titel">
</div>
<div class="col-12">
<select id="v2CaseType" class="form-select form-select-sm">
<option value="support">Support</option>
<option value="bogholderi">Bogholderi</option>
<option value="leverandor">Leverandør</option>
<option value="helhedsopgave">Helhedsopgave</option>
<option value="andet">Andet</option>
</select>
</div>
</div>
<div class="emails-v2-right-actions mt-2">
<button id="v2CreateCase" class="btn btn-sm btn-primary">Opret sag</button>
<h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
</button>
<button id="v2Archive" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-archive me-1"></i>Arkivér
</button>
${email.linked_case_id ? '' : `
<button id="v2Processed" class="btn btn-sm btn-outline-success">
<i class="bi bi-check2 me-1"></i>Behandlet
</button>`}
</div>
</div>
<div class="emails-v2-card">
<h6>Leverandør faktura</h6>
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
<button id="v2AutoParse" class="btn btn-sm btn-outline-primary">Find tråd/sag automatisk</button>
</div>
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
@ -1210,16 +1734,14 @@
</div>
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
</div>
<div class="emails-v2-card">
<h6>Auto match til kunde/sag</h6>
<hr>
<h6>Automatisk kundematch</h6>
<div class="emails-v2-right-actions">
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
</div>
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</div>
</details>
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
@ -1245,6 +1767,11 @@
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
button.addEventListener('click', () => {
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
});
});
state.vendorSuggestion = null;
state.domainCustomerSuggestion = null;
@ -1253,6 +1780,14 @@
renderDomainCustomerSuggestion();
renderWorkflowPreview();
loadWorkflowPreviewCurrent();
const vendorClassifications = new Set(['invoice', 'order_confirmation', 'freight_note']);
if (email.supplier_id || vendorClassifications.has(String(email.classification || '').toLowerCase())
|| email.extracted_vendor_name || email.extracted_vendor_cvr) {
extractVendorSuggestionCurrent();
} else {
state.vendorSuggestion = {};
renderClassificationDecision();
}
setMailStatus(`Email #${email.id} vises`);
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
@ -1262,6 +1797,101 @@
});
}
async function uploadEmailFiles(files) {
const accepted = Array.from(files || []).filter((file) =>
/\.(eml|msg)$/i.test(file.name || '')
);
if (!accepted.length) {
setListStatus('Vælg .eml- eller .msg-filer');
return;
}
const zone = document.getElementById('v2UploadZone');
const label = document.getElementById('v2UploadLabel');
const original = label?.innerHTML;
if (zone) {
zone.classList.add('busy');
}
if (label) {
label.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>Importerer ${accepted.length} mail${accepted.length === 1 ? '' : 's'}…`;
}
try {
const formData = new FormData();
accepted.forEach((file) => formData.append('files', file));
const result = await apiFetch('/api/v1/emails/upload', {
method: 'POST',
body: formData,
});
const uploaded = Number(result?.uploaded || 0);
const duplicates = Number(result?.duplicates || 0);
setListStatus(`${uploaded} importeret${duplicates ? ` · ${duplicates} fandtes allerede` : ''}`);
state.filter = 'active';
state.selectedEmailId = null;
await loadEmails();
} catch (error) {
setListStatus(`Import fejlede: ${error.message}`);
} finally {
if (zone) {
zone.classList.remove('busy', 'dragover');
}
if (label) label.innerHTML = original;
}
}
function setupUploadZone() {
const zone = document.getElementById('v2UploadZone');
const input = document.getElementById('v2UploadInput');
if (!zone || !input || zone.dataset.ready === 'true') return;
zone.dataset.ready = 'true';
zone.addEventListener('click', () => input.click());
zone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.click();
}
});
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.add('dragover');
}));
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.remove('dragover');
}));
zone.addEventListener('drop', (event) => uploadEmailFiles(event.dataTransfer?.files));
input.addEventListener('change', () => {
uploadEmailFiles(input.files);
input.value = '';
});
}
function selectAdjacentEmail(offset) {
if (!state.emails.length) return;
const current = state.emails.findIndex((email) => Number(email.id) === Number(state.selectedEmailId));
const next = Math.min(Math.max((current < 0 ? 0 : current) + offset, 0), state.emails.length - 1);
selectEmail(Number(state.emails[next].id));
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
const target = event.target;
const typing = target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
if (event.key === '/' && !typing) {
event.preventDefault();
document.getElementById('v2Search')?.focus();
} else if (!typing && event.key.toLowerCase() === 'j') {
selectAdjacentEmail(1);
} else if (!typing && event.key.toLowerCase() === 'k') {
selectAdjacentEmail(-1);
} else if (!typing && event.key.toLowerCase() === 'r') {
loadEmails();
}
});
}
function setupEvents() {
document.getElementById('v2Search')?.addEventListener('input', (event) => {
const value = String(event.target.value || '').trim();
@ -1273,7 +1903,8 @@
});
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
document.getElementById('v2FetchTest')?.addEventListener('click', fetchFromTestFolder);
setupUploadZone();
setupKeyboardShortcuts();
}
document.addEventListener('DOMContentLoaded', async () => {

View File

@ -17,10 +17,10 @@ templates = Jinja2Templates(directory="app")
@router.get("/emails", response_class=HTMLResponse)
async def emails_page(request: Request):
"""Email management UI - 3-column modern email interface"""
"""Email management UI - fast, simplified workflow."""
return templates.TemplateResponse(
"emails/frontend/emails.html",
{"request": request, "email_ui_version": "v1"}
"emails/frontend/emails_v2.html",
{"request": request, "email_ui_version": "v2"}
)

View File

@ -97,6 +97,15 @@ async def manual_map_product_for_line(
)
@router.post("/also/import-lines/{line_id}/create-product")
async def create_product_for_import_line(line_id: int, request: Request):
"""Create the missing Hub product from an ALSO line and map it immediately."""
return also_service.create_product_for_line(
line_id=line_id,
updated_by_user_id=_user_id_from_request(request),
)
@router.post("/also/import-upload")
async def upload_import_file(
request: Request,

View File

@ -2036,39 +2036,76 @@ class AlsoService:
}
)
aggregate_key = f"also-cloud-{customer_id}-{period_key}"
# A manual mapping can make the customer's products ready one at a time.
# Reuse the pending draft for the same customer/month rather than creating
# a separate order draft per product line.
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
f"ALSO Cloud {customer_name} - {period_key}",
customer_id,
_json_dumps(draft_lines),
"Genereret fra ALSO Cloud Billing approval",
1,
approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}),
f"also-cloud-{customer_id}-{period_key}",
),
"""SELECT id, lines_json
FROM ordre_drafts
WHERE customer_id = %s
AND invoice_aggregate_key = %s
AND sync_status = 'pending'
ORDER BY id ASC
LIMIT 1""",
(customer_id, aggregate_key),
)
draft_id = int(draft["id"]) if draft and draft.get("id") else None
if draft and draft.get("id"):
existing_lines = draft.get("lines_json") or []
if isinstance(existing_lines, str):
try:
existing_lines = json.loads(existing_lines)
except (TypeError, ValueError):
existing_lines = []
existing_keys = {
str(item.get("line_key"))
for item in existing_lines
if isinstance(item, dict) and item.get("line_key")
}
merged_lines = list(existing_lines) + [
item for item in draft_lines if str(item.get("line_key")) not in existing_keys
]
execute_query(
"""UPDATE ordre_drafts
SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])),
)
draft_id = int(draft["id"])
else:
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
f"ALSO Cloud {customer_name} - {period_key}",
customer_id,
_json_dumps(draft_lines),
"Genereret fra ALSO Cloud Billing approval",
1,
approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}),
aggregate_key,
),
)
draft_id = int(draft["id"]) if draft and draft.get("id") else None
if not draft_id:
raise HTTPException(status_code=500, detail="Failed creating ordre draft from ALSO approval")
draft_ids.append(draft_id)
if draft_id not in draft_ids:
draft_ids.append(draft_id)
line_id_values = [int(line["id"]) for line in group_lines]
placeholders = ",".join(["%s"] * len(line_id_values))
@ -2434,6 +2471,7 @@ class AlsoService:
""",
(line_id,),
)
completion = self._complete_import_job(int(line["import_job_id"]))
return {
"success": True,
@ -2448,6 +2486,7 @@ class AlsoService:
"matching_result": matching_result,
"validation_result": validation_result,
"approval_result": approval_result,
"completion": completion,
"line": refreshed_line,
}
@ -2546,6 +2585,7 @@ class AlsoService:
""",
(line_id,),
)
completion = self._complete_import_job(int(line["import_job_id"]))
return {
"success": True,
@ -2562,9 +2602,56 @@ class AlsoService:
"matching_result": matching_result,
"validation_result": validation_result,
"approval_result": approval_result,
"completion": completion,
"line": refreshed_line,
}
def create_product_for_line(
self,
*,
line_id: int,
updated_by_user_id: Optional[int],
) -> Dict[str, Any]:
"""Create a Hub subscription product from an ALSO line, then persist its mapping."""
self._assert_enabled()
line = execute_query_single(
"SELECT * FROM also_import_lines WHERE id = %s",
(line_id,),
)
if not line:
raise HTTPException(status_code=404, detail="ALSO import line not found")
if line.get("matched_product_id"):
raise HTTPException(status_code=409, detail="Linjen er allerede koblet til et Hub-produkt")
material_number = _normalized_text(line.get("material_number"))
product_name = _normalized_text(line.get("product_name"))
if not material_number or not product_name:
raise HTTPException(
status_code=400,
detail="Linjen mangler produktnavn eller ALSO-materialenummer og kan ikke oprette en vare",
)
vendor = _normalized_text(line.get("vendor")) or "ALSO"
existing = execute_query_single(
"""SELECT id FROM products
WHERE deleted_at IS NULL AND (sku_internal = %s OR supplier_sku = %s)
ORDER BY id ASC LIMIT 1""",
(material_number, material_number),
)
product_created = not bool(existing and existing.get("id"))
product_id = int(existing["id"]) if existing and existing.get("id") else self._create_local_product_for_line(line, vendor=vendor)
if not product_id:
raise HTTPException(status_code=500, detail="Kunne ikke oprette Hub-produktet")
result = self.manual_map_product_for_line(
line_id=line_id,
product_id=product_id,
notes="Product created from ALSO Cloud Marketplace UI",
updated_by_user_id=updated_by_user_id,
)
result["product_created"] = product_created
return result
def upsert_product_mapping(self, payload: AlsoProductMappingUpsert) -> Dict[str, Any]:
self._assert_enabled()
rows = execute_query(

View File

@ -655,7 +655,7 @@ def _has_boss_access(current_user: dict) -> bool:
(int(current_user_id),),
) or []
names = [str(r.get("name") or "") for r in rows]
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
tokens = ("admin", "manager", "leder", "chef")
return any(any(token in name for token in tokens) for name in names)
@ -673,6 +673,15 @@ def _get_next_unassigned_case() -> Optional[dict]:
WHERE deleted_at IS NULL
AND ansvarlig_bruger_id IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND (
LOWER(COALESCE(template_key, '')) IN ('ticket', 'support')
OR EXISTS (
SELECT 1
FROM groups support_group
WHERE support_group.id = sag_sager.assigned_group_id
AND LOWER(support_group.name) LIKE ANY(ARRAY['%support%', '%teknik%', '%technician%'])
)
)
ORDER BY
CASE
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0

View File

@ -1,4 +1,5 @@
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@ -144,12 +145,16 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.message_text,
m.requires_manual_ack,
m.created_at,
m.read_at,
receipt.read_at,
receipt.acknowledged_at,
COALESCE(NULLIF(sender.full_name, ''), sender.username, ('Bruger #' || sender.user_id::text)) AS sender_name,
COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name
FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = %s
WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS (
@ -161,21 +166,24 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC
LIMIT %s
""",
(int(user_id), int(user_id), int(user_id), safe_limit),
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
) or []
unread_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM bottom_bar_messages
WHERE read_at IS NULL
AND sender_user_id <> %s
FROM bottom_bar_messages m
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = %s
WHERE receipt.read_at IS NULL
AND m.sender_user_id <> %s
AND (
recipient_user_id = %s
OR recipient_user_id IS NULL
m.recipient_user_id = %s
OR m.recipient_user_id IS NULL
)
""",
(int(user_id), int(user_id)),
(int(user_id), int(user_id), int(user_id)),
) or {}
unread_count = _safe_count(unread_row)
@ -196,7 +204,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("read_at") is not None,
"is_acknowledged": row.get("acknowledged_at") is not None,
}
)
@ -214,31 +222,40 @@ def mark_user_messages_read(user_id: Optional[int], partner_user_id: Optional[in
params: List[Any] = [int(user_id)]
if partner_user_id is not None and int(partner_user_id) > 0:
where_clause = """
recipient_user_id = %s
AND read_at IS NULL
AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
AND m.sender_user_id <> %s
"""
params.append(int(user_id))
where_clause += " AND sender_user_id = %s"
where_clause += " AND m.sender_user_id = %s"
params.append(int(partner_user_id))
else:
where_clause = """
(recipient_user_id = %s OR recipient_user_id IS NULL)
AND read_at IS NULL
AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
AND m.sender_user_id <> %s
"""
params.append(int(user_id))
row = execute_query_single(
f"""
UPDATE bottom_bar_messages
SET read_at = CURRENT_TIMESTAMP
WHERE {where_clause}
RETURNING COUNT(*) OVER() AS affected_count
WITH eligible AS (
SELECT m.id
FROM bottom_bar_messages m
LEFT JOIN bottom_bar_message_receipts r
ON r.message_id = m.id AND r.user_id = %s
WHERE {where_clause}
AND r.read_at IS NULL
), updated AS (
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at)
SELECT id, %s, CURRENT_TIMESTAMP FROM eligible
ON CONFLICT (message_id, user_id) DO UPDATE
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at)
RETURNING message_id
)
SELECT COUNT(*) AS affected_count FROM updated
""",
tuple(params),
tuple([int(user_id)] + params + [int(user_id)]),
) or {}
return int(row.get("affected_count") or 0)
@ -251,14 +268,18 @@ def acknowledge_message(user_id: Optional[int], message_id: int) -> bool:
ensure_bottom_bar_messages_schema()
row = execute_query_single(
"""
UPDATE bottom_bar_messages
SET read_at = CURRENT_TIMESTAMP
WHERE id = %s
AND recipient_user_id = %s
AND read_at IS NULL
RETURNING id
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
SELECT m.id, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM bottom_bar_messages m
WHERE m.id = %s
AND (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND m.sender_user_id <> %s
ON CONFLICT (message_id, user_id) DO UPDATE
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at),
acknowledged_at = COALESCE(bottom_bar_message_receipts.acknowledged_at, EXCLUDED.acknowledged_at)
RETURNING message_id AS id
""",
(int(message_id), int(user_id)),
(int(user_id), int(message_id), int(user_id), int(user_id)),
) or {}
return bool(row.get("id"))
@ -332,19 +353,13 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool:
group_names = _get_user_group_names(user_id)
if not group_names:
# Fail-open for authenticated users if group mapping is missing.
return True
return False
leadership_tokens = (
"admin",
"manager",
"leder",
"chef",
"teknik",
"technician",
"support",
"drift",
"it",
)
return any(
any(token in group for token in leadership_tokens)
@ -580,20 +595,35 @@ def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Di
}
def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
def get_unassigned_open_cases(limit: int = 25, support_only: bool = False) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 25), 100))
support_filter = """
AND (
LOWER(COALESCE(s.template_key, '')) IN ('ticket', 'support')
OR EXISTS (
SELECT 1
FROM groups support_group
WHERE support_group.id = s.assigned_group_id
AND LOWER(support_group.name) LIKE ANY(ARRAY['%%support%%', '%%teknik%%', '%%technician%%'])
)
)
""" if support_only else ""
rows = execute_query(
"""
f"""
SELECT
s.id,
s.titel,
s.priority,
s.created_at,
s.updated_at
s.updated_at,
EXTRACT(EPOCH FROM (NOW() - s.created_at))::int AS age_seconds,
c.name AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
{support_filter}
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
@ -601,12 +631,13 @@ def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
) or []
count_row = execute_query_single(
"""
f"""
SELECT COUNT(*)::int AS count
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
{support_filter}
"""
)
@ -618,17 +649,20 @@ def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
"priority": row.get("priority") or "normal",
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
"age_seconds": int(row.get("age_seconds") or 0),
"customer_name": row.get("customer_name"),
}
for row in rows
],
"count": _safe_count(count_row),
"filter_meta": {
"route": "/api/v1/bottom-bar/boss/unassigned-cases",
"query": {"limit": limit_safe, "only_open": True, "only_unassigned": True},
"query": {"limit": limit_safe, "only_open": True, "only_unassigned": True, "support_only": support_only},
"sql_guarantee": [
"s.deleted_at IS NULL",
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
"s.ansvarlig_bruger_id IS NULL",
*(["Supporttype eller supportgruppe"] if support_only else []),
],
},
}
@ -835,22 +869,25 @@ def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"context_key": "global",
"global": [
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
{"id": "new_case", "label": "Ny sag", "action": "/sag/new", "icon": "bi-plus-circle"},
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
{"id": "start_timer", "label": "Skift/start timer", "command": "switch_timer", "icon": "bi-stopwatch"},
{"id": "log_time", "label": "Tidsoversigt", "action": "/timetracking/registrations", "icon": "bi-clock-history"},
{"id": "add_note", "label": "Ny personlig note", "command": "open_notes", "icon": "bi-journal-plus"},
],
"context": [],
}
if normalized.startswith("/sag"):
case_match = re.match(r"^/sag/(\d+)(?:/v3)?/?$", normalized)
if case_match:
case_id = int(case_match.group(1))
payload["context_key"] = "sag"
payload["case_id"] = case_id
payload["context"] = [
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
{"id": "case_mail", "label": "Mail", "action": "/emails"},
{"id": "case_relation", "label": "Relation", "action": "/customers"},
{"id": "case_tag", "label": "Tag", "action": "/tags"},
{"id": "case_time", "label": "Registrér tid", "command": "case_add:time", "icon": "bi-clock"},
{"id": "case_note", "label": "Tilføj kommentar", "command": "case_add:note", "icon": "bi-chat-left-text"},
{"id": "case_mail", "label": "Send mail", "command": "case_add:email", "icon": "bi-envelope"},
{"id": "case_subscription", "label": "Abonnement", "command": "case_add:subscription", "icon": "bi-arrow-repeat"},
]
elif normalized.startswith("/hardware"):
payload["context_key"] = "hardware"
@ -987,8 +1024,11 @@ def build_bottom_bar_state(
technicians_today: List[Dict[str, Any]] = []
escalation_cases: List[Dict[str, Any]] = []
unassigned_cases: List[Dict[str, Any]] = []
support_unassigned_count = 0
if can_view_boss:
support_unassigned_open_cases = get_unassigned_open_cases(limit=12, support_only=True)
support_unassigned_count = int(support_unassigned_open_cases.get("count") or 0)
team_workload = execute_query(
"""
SELECT
@ -1091,8 +1131,10 @@ def build_bottom_bar_state(
"id": row.get("id"),
"titel": row.get("title"),
"priority": row.get("priority"),
"customer_name": row.get("customer_name"),
"age_seconds": int(row.get("age_seconds") or 0),
}
for row in (unassigned_open_cases.get("items") or [])
for row in (support_unassigned_open_cases.get("items") or [])
]
sections = {
@ -1161,6 +1203,7 @@ def build_bottom_bar_state(
"can_view": can_view_boss,
"stats": {
"unassigned": status.get("sager_unassigned", 0),
"support_unassigned": support_unassigned_count,
"active_employees": _safe_count(
execute_query_single(
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"

View File

@ -502,12 +502,12 @@
<div class="calendar-shell fade-up">
<div class="calendar-toolbar">
<div class="view-buttons" id="viewButtons">
<button type="button" data-view="dayGridMonth" class="active">Maaned</button>
<button type="button" data-view="dayGridMonth" class="active">Måned</button>
<button type="button" data-view="timeGridWeek">Uge</button>
<button type="button" data-view="timeGridDay">Dag</button>
<button type="button" data-view="listWeek">Agenda</button>
</div>
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div>
<div class="status-bar" id="rangeLabel">Indlæser periode...</div>
</div>
<div id="calendar"></div>
<div class="calendar-legend">

View File

@ -338,6 +338,12 @@
<span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
</div>
{% if hardware.current_location_id %}
<div class="info-row">
<span class="info-label">Rækkefølge på lokation</span>
<span class="info-value"><span class="badge bg-primary">Nr. {{ hardware.location_display_order or '—' }}</span></span>
</div>
{% endif %}
{% if hardware.eset_uuid %}
<div class="info-row">
<span class="info-label">ESET UUID</span>
@ -717,6 +723,7 @@
{% if uisp_device %}
{% set overview = uisp_device.overview or {} %}
<div class="row g-3 small">
<div class="col-md-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp_device.hostname or uisp_device.display_name or uisp_device.name or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>
@ -876,7 +883,7 @@
<div class="row g-3 mb-3">
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Total omsaetning</div>
<div class="stat-label">Total omsætning</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.total_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Fra alle ordrelinjer pa dette asset</div>
</div>
@ -951,7 +958,7 @@
<th>Dato</th>
<th>Titel</th>
<th>Status</th>
<th class="text-end">Beloeb</th>
<th class="text-end">Beløb</th>
</tr>
</thead>
<tbody>
@ -1047,13 +1054,13 @@
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
</div>
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3">
Vaelg kunde og sag for at se hvad der bliver oprettet.
Vælg kunde og sag for at se, hvad der bliver oprettet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<select class="form-select" id="quickRentCustomerId" required>
<option value="">-- Vaelg kunde --</option>
<option value="">-- Vælg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}">{{ customer.navn }}</option>
{% endfor %}
@ -1473,11 +1480,11 @@
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er paakraevet.');
alert('Kunde, sag og startdato er påkrævet.');
return;
}
if (operationsMonthlyPrice <= 0) {
alert('Drift pr. maned skal vaere over 0.');
alert('Drift pr. måned skal være over 0.');
return;
}
@ -1541,14 +1548,14 @@
if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Vaelg kunde og sag for at se hvad der bliver oprettet.';
infoEl.textContent = 'Vælg kunde og sag for at se, hvad der bliver oprettet.';
submitBtn.disabled = false;
return;
}
try {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Tjekker abonnement paa sagen...';
infoEl.textContent = 'Tjekker abonnement på sagen...';
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json();
@ -1571,7 +1578,7 @@
} catch (error) {
submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3';
infoEl.textContent = `Preview fejl: ${error.message}. Du kan stadig prove at oprette.`;
infoEl.textContent = `Fejl i forhåndsvisning: ${error.message}. Du kan stadig prøve at oprette.`;
}
}

View File

@ -97,7 +97,7 @@
border-top: 1px solid rgba(0,0,0,0.1);
}
.btn {
.form-container .btn {
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 500;
@ -108,23 +108,23 @@
text-decoration: none;
}
.btn-primary {
.form-container .btn-primary {
background-color: var(--accent);
color: white;
}
.btn-primary:hover {
.form-container .btn-primary:hover {
background-color: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
}
.btn-secondary {
.form-container .btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
.form-container .btn-secondary:hover {
background-color: #5a6268;
}
@ -141,7 +141,7 @@
flex-direction: column-reverse;
}
.btn {
.form-container .btn {
width: 100%;
}
}

View File

@ -167,7 +167,7 @@
<div class="section-card" id="devicesSection">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<div class="status-pill" id="deviceStatus">Ingen data indlæst</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="runOnePcFullTest()">Test 1 PC (ALT)</button>
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
@ -219,7 +219,7 @@
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
</div>
<div id="contactResults" class="contact-results"></div>
<div id="contactHint" class="contact-muted">Tip: Skriv 2+ tegn og tryk Enter for at vaelge den forste.</div>
<div id="contactHint" class="contact-muted">Tip: Skriv mindst 2 tegn, og tryk Enter for at vælge den første.</div>
</div>
<div class="mb-3">
@ -365,7 +365,7 @@
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
<div class="mt-3">
<label class="form-label small">Vaelg kontakt (ejer)</label>
<label class="form-label small">Vælg kontakt (ejer)</label>
<input type="text" class="form-control form-control-sm inline-contact-search" data-index="${index}" placeholder="Sog kontakt..." autocomplete="off">
<div id="inlineResults-${index}" class="inline-results"></div>
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
@ -409,7 +409,7 @@
async function searchInlineContacts(query, index) {
const results = document.getElementById(`inlineResults-${index}`);
if (!results) return;
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
@ -571,10 +571,10 @@
const query = document.getElementById('contactSearch').value.trim();
const results = document.getElementById('contactResults');
if (!query) {
results.innerHTML = '<div class="p-2 text-muted">Indtast soegning.</div>';
results.innerHTML = '<div class="p-2 text-muted">Indtast søgning.</div>';
return;
}
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
if (!response.ok) {

View File

@ -69,6 +69,19 @@ GENERIC_SEGMENT_TITLE_PATTERNS = (
)
def _normalize_service_location(value: Optional[str]) -> str:
normalized = str(value or "").lower()
normalized = normalized.replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
return re.sub(r"[^a-z0-9]+", "", normalized)
class InvoiceSyncReviewRequest(BaseModel):
line_number: int
action: str
connection_id: Optional[int] = None
note: Optional[str] = None
def _internet_customer_doc_dir() -> Path:
base = Path(settings.UPLOAD_DIR).resolve()
target = base / "internet_customer_docs"
@ -1247,6 +1260,231 @@ async def internet_connections_health():
return {"status": "healthy", "service": "internet-connections-module"}
@router.get("/internet-connections/invoice-sync-runs")
async def list_internet_invoice_sync_runs(
status: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
):
"""Show GlobalConnect invoices and their latest internet-sync outcome."""
rows = execute_query(
"""
WITH raw_invoice_keys AS (
SELECT si.id AS supplier_invoice_id, si.extraction_id, e.file_id,
si.invoice_number, COALESCE(si.vendor_name, e.vendor_name) AS vendor_name,
si.invoice_date, si.total_amount, si.currency, si.created_at
FROM supplier_invoices si
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
WHERE COALESCE(si.vendor_name, e.vendor_name, '') ILIKE '%%GlobalConnect%%'
UNION ALL
SELECT run.supplier_invoice_id, run.extraction_id, run.file_id, run.invoice_number,
run.vendor_name, run.invoice_date, NULL::numeric, NULL::varchar, run.processed_at
FROM internet_connections_invoice_sync_runs run
WHERE NOT EXISTS (
SELECT 1 FROM supplier_invoices si
WHERE si.id = run.supplier_invoice_id
OR (run.supplier_invoice_id IS NULL AND si.invoice_number = run.invoice_number
AND COALESCE(si.vendor_name, '') = COALESCE(run.vendor_name, ''))
)
),
invoice_keys AS (
SELECT DISTINCT ON (
TRIM(COALESCE(invoice_number, '')),
LOWER(TRIM(COALESCE(vendor_name, '')))
) *
FROM raw_invoice_keys
ORDER BY
TRIM(COALESCE(invoice_number, '')),
LOWER(TRIM(COALESCE(vendor_name, ''))),
supplier_invoice_id NULLS LAST,
created_at DESC
)
SELECT key.*,
COALESCE(
latest.status,
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
) AS processing_status,
latest.id AS run_id, latest.connections_synced, latest.connections_created,
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,
GREATEST(COALESCE(latest.skipped_lines, 0) - COALESCE(review.resolved_lines, 0), 0) AS unresolved_lines,
COALESCE(review.decisions, '[]'::jsonb) AS review_decisions
FROM invoice_keys key
LEFT JOIN incoming_files file ON file.file_id = key.file_id
LEFT JOIN LATERAL (
SELECT run.*
FROM internet_connections_invoice_sync_runs run
WHERE (key.supplier_invoice_id IS NOT NULL AND run.supplier_invoice_id = key.supplier_invoice_id)
OR (key.extraction_id IS NOT NULL AND run.extraction_id = key.extraction_id)
OR (run.invoice_number = key.invoice_number
AND COALESCE(run.vendor_name, '') = COALESCE(key.vendor_name, ''))
ORDER BY run.processed_at DESC, run.id DESC
LIMIT 1
) latest ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(DISTINCT history.connection_id)::integer AS connection_count
FROM internet_connections_history history
WHERE key.invoice_number IS NOT NULL
AND history.summary ILIKE ('%%' || key.invoice_number || '%%')
AND history.event_type IN (
'connection_created_from_supplier_invoice', 'supplier_invoice_sync_changed',
'supplier_invoice_ip_range_changed', 'supplier_invoice_ip_range_created'
)
) legacy ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS resolved_lines,
COALESCE(
jsonb_agg(jsonb_build_object(
'line_number', decision.line_number,
'action', decision.action,
'connection_id', decision.connection_id,
'note', decision.note,
'resolved_at', decision.resolved_at
) ORDER BY decision.line_number),
'[]'::jsonb
) AS decisions
FROM internet_connections_invoice_review_decisions decision
WHERE decision.run_id = latest.id
) review ON TRUE
WHERE (%s IS NULL OR COALESCE(
latest.status,
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
) = %s)
ORDER BY COALESCE(latest.processed_at, file.processed_at, key.created_at) DESC
LIMIT %s
""",
(status, status, limit),
) or []
items = [dict(row) for row in rows]
return {
"items": items,
"summary": {
"total": len(items),
"success": sum(item.get("processing_status") in {"success", "legacy_success"} for item in items),
"warnings": sum(item.get("processing_status") in {"warning", "skipped", "not_logged"} for item in items),
"errors": sum(item.get("processing_status") == "error" for item in items),
},
}
@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"}:
raise HTTPException(status_code=400, detail="Ugyldig kontrolhandling")
if data.action == "ignore" and not str(data.note or "").strip():
raise HTTPException(status_code=400, detail="Angiv en årsag, når en linje ignoreres")
run = execute_query_single(
"SELECT * 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")
result = run.get("result_json") or {}
skipped_items = result.get("skipped_items") or []
audit_item = next(
(item for item in skipped_items if int(item.get("line_number") or -1) == data.line_number),
None,
)
if not audit_item:
raise HTTPException(status_code=404, detail="Kontrollinjen blev ikke fundet")
target_connection_id = data.connection_id
if data.action in {"link_existing", "create_separate"}:
if data.action == "link_existing":
if not target_connection_id:
raise HTTPException(status_code=400, detail="Vælg en eksisterende forbindelse")
target = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id = %s AND deleted_at IS NULL",
(target_connection_id,),
)
if not target:
raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet")
else:
reference = str(audit_item.get("provider_reference") or "").strip()
service_address = str(audit_item.get("service_address") or "").strip()
target_connection_id = execute_insert(
"""
INSERT INTO internet_connections_connections (
name, provider, address, status, circuit_number, notes,
allocation_model, value_type
)
VALUES (%s, 'GlobalConnect A/S', %s, 'pending', %s, %s, 'dedicated', 'other')
RETURNING id
""",
(
f"Afventer kontrol · {reference or 'ukendt reference'}",
service_address or None,
reference or None,
f"Oprettet manuelt under kontrol af faktura {run.get('invoice_number')}.",
),
)
if audit_item.get("classification") == "ip_range":
extraction_line = execute_query_single(
"""
SELECT *
FROM extraction_lines
WHERE extraction_id = %s AND line_number = %s
ORDER BY line_id DESC
LIMIT 1
""",
(run.get("extraction_id"), data.line_number),
)
if not extraction_line:
raise HTTPException(status_code=404, detail="Den oprindelige fakturalinje blev ikke fundet")
from app.billing.backend.supplier_invoices import _upsert_globalconnect_ip_range
range_id = _upsert_globalconnect_ip_range(
int(target_connection_id),
dict(extraction_line),
str(run.get("invoice_number") or "ukendt"),
)
if not range_id:
raise HTTPException(status_code=400, detail="IP-rangen kunne ikke forbindes")
execute_query(
"""
INSERT INTO internet_connections_invoice_review_decisions (
run_id, line_number, action, connection_id, note
)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (run_id, line_number) DO UPDATE
SET action = EXCLUDED.action,
connection_id = EXCLUDED.connection_id,
note = EXCLUDED.note,
resolved_at = CURRENT_TIMESTAMP
""",
(run_id, data.line_number, data.action, target_connection_id, (data.note or "").strip() or None),
fetch=False,
)
resolved = execute_query_single(
"SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s",
(run_id,),
) or {"count": 0}
skipped_count = len(skipped_items)
remaining = max(skipped_count - int(resolved.get("count") or 0), 0)
if remaining == 0:
execute_query(
"UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s",
(run_id,),
fetch=False,
)
return {
"status": "resolved",
"run_id": run_id,
"line_number": data.line_number,
"action": data.action,
"connection_id": target_connection_id,
"remaining": remaining,
}
@router.get("/internet-connections", response_model=List[dict])
async def list_connections(
q: Optional[str] = Query(None),
@ -1486,6 +1724,99 @@ async def get_connection(connection_id: int):
return connection
@router.get("/internet-connections/{connection_id:int}/cross-field-ports")
async def get_connection_cross_field_ports(connection_id: int):
"""Find cross-field ports related by customer or service-location address."""
connection = execute_query_single(
"""
SELECT ic.id, ic.customer_id, ic.address, parent.address AS parent_address
FROM internet_connections_connections ic
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
WHERE ic.id = %s AND ic.deleted_at IS NULL
""",
(connection_id,),
)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
rows = execute_query(
"""
SELECT o.id AS outlet_id, o.outlet_number, o.category, o.patch_panel,
o.patch_port, o.switch_name, o.switch_port, o.status,
o.is_active, o.is_wan, o.customer_id,
p.id AS cross_field_port_id, p.port_number, p.port_order,
cf.id AS cross_field_id, cf.name AS cross_field_name,
l.id AS location_id, l.name AS location_name, l.customer_id AS location_customer_id,
l.address_street, l.address_postal_code, l.address_city,
h.id AS switch_hardware_id, h.model AS switch_hardware_model
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id AND l.deleted_at IS NULL
LEFT JOIN locations_cross_field_ports p ON p.id = o.cross_field_port_id
LEFT JOIN locations_cross_fields cf ON cf.id = p.cross_field_id AND cf.deleted_at IS NULL
LEFT JOIN hardware h ON h.id = o.switch_hardware_id AND h.deleted_at IS NULL
WHERE o.deleted_at IS NULL
AND o.is_active = TRUE
AND o.cross_field_port_id IS NOT NULL
ORDER BY l.name, cf.display_order, p.port_order, o.id
"""
) or []
customer_id = int(connection.get("customer_id") or 0)
target_addresses = {
_normalize_service_location(value)
for value in (connection.get("address"), connection.get("parent_address"))
if value
}
target_addresses.discard("")
ports = []
for row in rows:
item = dict(row)
def clean_location_part(value: object) -> str:
text = str(value or "").strip()
return "" if text.lower() in {"none", "null", "-"} else text
location_address = ", ".join(filter(None, [
clean_location_part(item.get("address_street")),
" ".join(filter(None, [
clean_location_part(item.get("address_postal_code")),
clean_location_part(item.get("address_city")),
])).strip(),
]))
normalized_location = _normalize_service_location(location_address)
customer_match = bool(
customer_id
and customer_id in {
int(item.get("customer_id") or 0),
int(item.get("location_customer_id") or 0),
}
)
address_match = bool(
normalized_location
and any(
target == normalized_location
or (len(normalized_location) >= 5 and target.startswith(normalized_location))
for target in target_addresses
)
)
if not customer_match and not address_match:
continue
item["network_role"] = "wan" if item.get("is_wan") else "lan"
item["is_faulty"] = str(item.get("status") or "").lower() == "faulty"
item["location_address"] = location_address or None
ports.append(item)
return {
"items": ports,
"summary": {
"total": len(ports),
"lan": sum(item["network_role"] == "lan" for item in ports),
"wan": sum(item["network_role"] == "wan" for item in ports),
"faulty": sum(bool(item["is_faulty"]) for item in ports),
},
}
@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
where_parts = [" AND ic.parent_id = %s "]

View File

@ -556,6 +556,30 @@
<div id="pricingHistoryList"></div>
</div>
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-0">Porte i krydsfelt</h5>
<div class="small text-muted" id="crossFieldPortsSummary"></div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Lokation / krydsfelt</th>
<th>Port</th>
<th>Vægstik</th>
<th>Switch</th>
<th>Netværk</th>
<th>Status</th>
</tr>
</thead>
<tbody id="crossFieldPortsBody"></tbody>
</table>
</div>
</div>
<div class="detail-panel p-4">
<h5 class="mb-3">Historik</h5>
<div id="historyList"></div>
@ -1296,6 +1320,7 @@
pricingHistoryResponse,
contractsResponse,
historyResponse,
crossFieldPortsResponse,
] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`),
fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`),
@ -1305,6 +1330,7 @@
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]);
if (!connectionResponse.ok) {
@ -1321,6 +1347,7 @@
const pricingHistory = await pricingHistoryResponse.json();
const contracts = await contractsResponse.json();
const history = await historyResponse.json();
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : [];
@ -1337,6 +1364,44 @@
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts);
}
function renderCrossFieldPorts(payload) {
const panel = document.getElementById('crossFieldPortsPanel');
const body = document.getElementById('crossFieldPortsBody');
const summaryEl = document.getElementById('crossFieldPortsSummary');
const items = Array.isArray(payload?.items) ? payload.items : [];
const summary = payload?.summary || {};
panel.classList.toggle('d-none', items.length === 0);
if (!items.length) {
body.innerHTML = '';
return;
}
summaryEl.textContent = `${summary.total || items.length} porte · ${summary.lan || 0} LAN · ${summary.wan || 0} WAN · ${summary.faulty || 0} defekte`;
body.innerHTML = items.map((port) => {
const status = String(port.status || '').toLowerCase();
const statusLabel = port.is_faulty ? 'Defekt' : (status === 'reserved' ? 'Reserveret' : 'Aktiv');
const statusClass = port.is_faulty ? 'bg-danger' : (status === 'reserved' ? 'bg-warning text-dark' : 'bg-success');
const roleClass = port.network_role === 'wan' ? 'bg-primary' : 'bg-info text-dark';
const switchLabel = port.switch_name || port.switch_hardware_model || '-';
return `
<tr class="${port.is_faulty ? 'table-danger' : ''}">
<td>
<a href="/app/locations/${port.location_id}?tab=cross-field" class="fw-semibold text-decoration-none">
${escapeHtml(port.location_name || '-')}
</a>
<div class="small text-muted">${escapeHtml(port.cross_field_name || '-')}</div>
</td>
<td><strong>${escapeHtml(port.port_number || port.patch_port || '-')}</strong></td>
<td>${escapeHtml(port.outlet_number || '-')}</td>
<td>${escapeHtml(switchLabel)}${port.switch_port ? ` · port ${escapeHtml(port.switch_port)}` : ''}</td>
<td><span class="badge ${roleClass}">${port.network_role === 'wan' ? 'WAN' : 'LAN'}</span></td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
</tr>
`;
}).join('');
}
function renderCore(connection, pricing, summary) {

View File

@ -137,6 +137,24 @@
padding: 1rem;
}
.invoice-sync-status {
display:inline-flex;
align-items:center;
border-radius:999px;
padding:.3rem .65rem;
font-size:.72rem;
font-weight:800;
white-space:nowrap;
}
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:#146c43; }
.invoice-sync-status.warning,
.invoice-sync-status.skipped,
.invoice-sync-status.not_logged { background:rgba(255,193,7,.2); color:#765a02; }
.invoice-sync-status.error { background:rgba(220,53,69,.14); color:#b02a37; }
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:#084298; }
.invoice-sync-error { max-width:420px; white-space:normal; color:#b02a37; font-size:.8rem; }
@media (max-width: 991px) {
.internet-toolbar {
grid-template-columns: 1fr;
@ -155,6 +173,9 @@
<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()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
@ -327,10 +348,58 @@
</table>
</div>
</div>
<div class="internet-panel p-4 mb-4" 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">
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option>
<option value="success">Gennemført</option>
<option value="warning">Kræver kontrol</option>
<option value="error">Fejl</option>
<option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option>
</select>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fakturaer</div><div class="internet-kpi-value" id="invoiceMetricTotal">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Gennemført</div><div class="internet-kpi-value text-success" id="invoiceMetricSuccess">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Kræver kontrol</div><div class="internet-kpi-value text-warning" id="invoiceMetricWarnings">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fejl</div><div class="internet-kpi-value text-danger" id="invoiceMetricErrors">0</div></div></div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light"><tr><th>Faktura</th><th>Behandlet</th><th>Resultat</th><th>Forbindelser</th><th>IP-ranges</th><th>Fejl / bemærkning</th></tr></thead>
<tbody id="invoiceSyncTableBody"><tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr></tbody>
</table>
</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">
<div class="modal-header">
<div><h5 class="modal-title mb-1">Kontrollér internetfaktura <span id="invoiceReviewNumber"></span></h5><div class="internet-mini" id="invoiceReviewSummary"></div></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</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 id="invoiceReviewLines"></div>
</div>
</div>
</div>
</div>
<script>
let allConnections = [];
let invoiceSyncItems = [];
let activeTab = 'all';
let subscriptionOptions = [];
@ -405,6 +474,165 @@
return fallback;
}
function escapeInvoiceText(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function invoiceSyncBadge(status) {
const labels = {
success: 'Gennemført',
legacy_success: 'Ældre · gennemført',
warning: 'Kræver kontrol',
skipped: 'Sprunget over',
error: 'Fejl',
not_logged: 'Ingen detaljer',
};
return `<span class="invoice-sync-status ${escapeInvoiceText(status)}">${labels[status] || escapeInvoiceText(status || 'Ukendt')}</span>`;
}
function formatInvoiceSyncDate(value) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? escapeInvoiceText(value) : date.toLocaleString('da-DK');
}
function invoiceSyncMessage(item) {
if (item.error_message) return `<div class="invoice-sync-error">${escapeInvoiceText(item.error_message)}</div>`;
if (item.processing_status === 'warning') {
const skipped = Number(item.skipped_lines || 0);
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const reasons = [...new Set(skippedItems.map(entry => entry.reason).filter(Boolean))];
return `<div class="text-warning small mb-2">${Number(item.unresolved_lines ?? skipped)} af ${skipped} linje(r) mangler kontrol${reasons.length ? `: ${escapeInvoiceText(reasons.join('; '))}` : ''}</div>${item.run_id ? `<button class="btn btn-sm btn-warning" type="button" onclick="openInvoiceReview(${Number(item.run_id)})"><i class="bi bi-check2-square me-1"></i>Kontrollér</button>` : ''}`;
}
if (item.processing_status === 'skipped') return '<div class="text-warning small">Fakturaen gav ingen internetdata til synkronisering.</div>';
if (item.processing_status === 'not_logged') return '<div class="text-muted small">Ældre faktura uden gemt behandlingsresultat.</div>';
if (item.processing_status === 'legacy_success') return '<div class="text-muted small">Fundet via eksisterende forbindelseshistorik.</div>';
return '<div class="text-muted small">Ingen fejl registreret.</div>';
}
async function loadInvoiceSyncRuns() {
const status = document.getElementById('invoiceSyncStatusFilter').value;
const params = new URLSearchParams({limit: '200'});
if (status) params.set('status', status);
const body = document.getElementById('invoiceSyncTableBody');
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr>';
try {
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs?${params.toString()}`);
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Kunne ikke hente fakturabehandling.'));
const payload = await response.json();
const items = Array.isArray(payload.items) ? payload.items : [];
invoiceSyncItems = items;
const summary = payload.summary || {};
document.getElementById('invoiceMetricTotal').textContent = String(summary.total || 0);
document.getElementById('invoiceMetricSuccess').textContent = String(summary.success || 0);
document.getElementById('invoiceMetricWarnings').textContent = String(summary.warnings || 0);
document.getElementById('invoiceMetricErrors').textContent = String(summary.errors || 0);
if (!items.length) {
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Ingen fakturaer matcher filteret.</td></tr>';
return;
}
body.innerHTML = items.map(item => `
<tr>
<td><div class="fw-semibold">${escapeInvoiceText(item.invoice_number || '-')}</div><div class="internet-mini">${escapeInvoiceText(item.vendor_name || 'GlobalConnect')} · ${escapeInvoiceText(item.invoice_date || '-')}</div></td>
<td>${formatInvoiceSyncDate(item.processed_at)}</td>
<td>${invoiceSyncBadge(item.processing_status)}</td>
<td><strong>${Number(item.connections_synced || item.legacy_connection_count || 0)}</strong><div class="internet-mini">${Number(item.connections_created || 0)} nye · ${Number(item.connections_updated || 0)} opdateret</div></td>
<td><strong>${Number(item.ip_ranges_synced || 0)}</strong></td>
<td>${invoiceSyncMessage(item)}</td>
</tr>
`).join('');
} catch (error) {
body.innerHTML = `<tr><td colspan="6" class="text-danger py-4">${escapeInvoiceText(error.message || 'Kunne ikke indlæse fakturabehandling.')}</td></tr>`;
}
}
function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
.map(item => `<option value="${Number(item.id)}" ${String(item.id) === String(selectedId) ? 'selected' : ''}>${escapeInvoiceText(item.circuit_number || item.name || `#${item.id}`)} · ${escapeInvoiceText(item.address || '-')}</option>`)
.join('');
}
function openInvoiceReview(runId) {
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
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`;
const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line);
return result;
}, {});
document.getElementById('invoiceReviewLines').innerHTML = Object.entries(groups).map(([reason, lines]) => `
<section class="mb-4">
<h6 class="mb-2">${escapeInvoiceText(reason)} <span class="badge bg-secondary">${lines.length}</span></h6>
<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' : ''}">
<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>
<div class="internet-mini">Reference: ${escapeInvoiceText(line.provider_reference || '-')} · Adresse: ${escapeInvoiceText(line.service_address || '-')}</div>
${line.ip_address ? `<div class="internet-mini">IP-range: ${escapeInvoiceText(line.ip_address)}</div>` : ''}
${decision ? `<div class="text-success small mt-2"><i class="bi bi-check-circle me-1"></i>Løst: ${escapeInvoiceText(decision.action)}${decision.note ? ` · ${escapeInvoiceText(decision.note)}` : ''}</div>` : ''}
</div>
${decision ? '' : `<div style="min-width:340px;">
<select class="form-select form-select-sm mb-2" id="reviewConnection-${runId}-${Number(line.line_number)}">${reviewConnectionOptions()}</select>
<input class="form-control form-control-sm mb-2" id="reviewNote-${runId}-${Number(line.line_number)}" placeholder="Begrundelse / note">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'link_existing')">Brug valgt</button>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'create_separate')">Opret separat</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'ignore')">Ignorer</button>
</div>
</div>`}
</div>
</div>`;
}).join('')}
</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) {
const connectionId = document.getElementById(`reviewConnection-${runId}-${lineNumber}`)?.value || null;
const note = document.getElementById(`reviewNote-${runId}-${lineNumber}`)?.value.trim() || '';
if (action === 'link_existing' && !connectionId) {
alert('Vælg først en eksisterende forbindelse.');
return;
}
if (action === 'ignore' && !note) {
alert('Skriv en begrundelse for at ignorere linjen.');
return;
}
if (action === 'create_separate' && !confirm('Opret en separat, afventende forbindelse for denne linje?')) return;
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}/review`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
line_number: lineNumber,
action,
connection_id: connectionId ? Number(connectionId) : null,
note: note || null,
}),
});
if (!response.ok) {
alert(await extractErrorMessage(response, 'Kontrollen kunne ikke gemmes.'));
return;
}
await loadInvoiceSyncRuns();
openInvoiceReview(runId);
}
async function loadInternetPage() {
const search = document.getElementById('searchInput').value.trim();
const provider = document.getElementById('providerFilter').value.trim();
@ -626,7 +854,7 @@
});
toggleCreateValueFields();
await loadSubscriptionOptions();
await loadInternetPage();
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
});
</script>
<datalist id="subscriptionLookupList"></datalist>

View File

@ -1136,7 +1136,7 @@
const payload = getFormPayload();
if (!payload.name) {
alert('Navn er paakraevet.');
alert('Navn er påkrævet.');
return;
}

View File

@ -853,9 +853,124 @@ def _replace_switch_port_if_confirmed(
)
def _validate_outlet_port_references(
*,
location_id: int,
cross_field_port_id: Optional[int],
switch_hardware_id: Optional[int],
switch_port: Optional[str],
) -> None:
"""Only allow active, explicitly created ports belonging to this location."""
if cross_field_port_id is not None:
cross_field_port = execute_query(
'''SELECT p.id
FROM locations_cross_field_ports p
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
WHERE p.id = %s
AND p.is_active = TRUE
AND cf.location_id = %s
AND cf.is_active = TRUE
AND cf.deleted_at IS NULL''',
(cross_field_port_id, location_id),
) or []
if not cross_field_port:
raise HTTPException(
status_code=400,
detail='Den valgte krydsfelt-port findes ikke eller er ikke aktiv på lokationen',
)
if switch_hardware_id is None:
return
hardware = execute_query(
'''SELECT id, hardware_specs
FROM hardware_assets
WHERE id = %s
AND current_location_id = %s
AND deleted_at IS NULL
AND LOWER(COALESCE(asset_type, '')) = 'netværk' ''',
(switch_hardware_id, location_id),
) or []
if not hardware:
raise HTTPException(status_code=400, detail='Den valgte switch findes ikke på lokationen')
if switch_port:
specs = hardware[0].get('hardware_specs') or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (TypeError, ValueError):
specs = {}
port_count = int((specs or {}).get('port_count') or 0)
valid_ports = {str(number) for number in range(1, port_count + 1)}
if str(switch_port).strip() not in valid_ports:
raise HTTPException(
status_code=400,
detail='Den valgte switch-port findes ikke blandt switchens oprettede porte',
)
def _validate_single_customer_wan(
*,
location_id: int,
customer_id: Optional[int],
is_wan: bool,
exclude_outlet_id: Optional[int] = None,
) -> None:
if not is_wan:
return
effective_customer = customer_id
if effective_customer is None:
location = execute_query(
'''SELECT customer_id
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL''',
(location_id,),
) or []
effective_customer = location[0].get('customer_id') if location else None
if effective_customer is None:
raise HTTPException(status_code=400, detail='Vælg en kunde, før porten markeres som WAN')
params = [effective_customer]
exclude_sql = ''
if exclude_outlet_id is not None:
exclude_sql = 'AND o.id <> %s'
params.append(exclude_outlet_id)
existing = execute_query(
f'''SELECT o.id, o.outlet_number
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
WHERE COALESCE(o.customer_id, l.customer_id) = %s
AND o.is_wan = TRUE
AND o.is_active = TRUE
AND o.deleted_at IS NULL
{exclude_sql}
LIMIT 1''',
tuple(params),
) or []
if existing:
outlet_name = existing[0].get('outlet_number') or f"#{existing[0]['id']}"
raise HTTPException(
status_code=409,
detail=f'Kunden har allerede WAN-porten {outlet_name}. Der kan kun være én WAN-port pr. kunde',
)
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
async def create_wall_outlet(data: WallOutletCreate):
_outlet_location(data.location_id)
_validate_outlet_port_references(
location_id=data.location_id,
cross_field_port_id=data.cross_field_port_id,
switch_hardware_id=data.switch_hardware_id,
switch_port=data.switch_port,
)
_validate_single_customer_wan(
location_id=data.location_id,
customer_id=data.customer_id,
is_wan=data.is_wan,
)
_replace_switch_port_if_confirmed(
switch_hardware_id=data.switch_hardware_id,
switch_name=data.switch_name,
@ -866,9 +981,9 @@ async def create_wall_outlet(data: WallOutletCreate):
try:
rows = execute_query(
"""INSERT INTO locations_wall_outlets
(location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active),
(location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.is_wan, data.status, data.notes, data.is_active),
) or []
except Exception as exc:
if 'unique' in str(exc).lower():
@ -888,12 +1003,24 @@ async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
if 'outlet_number' in changes:
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
current = execute_query(
'''SELECT switch_hardware_id, switch_name, switch_port
'''SELECT location_id, customer_id, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
(outlet_id,),
) or []
if not current:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
_validate_outlet_port_references(
location_id=changes.get('location_id', current[0]['location_id']),
cross_field_port_id=changes.get('cross_field_port_id', current[0].get('cross_field_port_id')),
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
switch_port=changes.get('switch_port', current[0].get('switch_port')),
)
_validate_single_customer_wan(
location_id=changes.get('location_id', current[0]['location_id']),
customer_id=changes.get('customer_id', current[0].get('customer_id')),
is_wan=changes.get('is_wan', current[0].get('is_wan', False)),
exclude_outlet_id=outlet_id,
)
_replace_switch_port_if_confirmed(
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
switch_name=changes.get('switch_name', current[0].get('switch_name')),

View File

@ -488,7 +488,59 @@ def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Qu
# ============================================================================
# 4. GET /app/locations/{id} - Detail view (HTML)
# 4. GET /app/locations/cross-fields - Cross-field overview
# ============================================================================
@router.get("/app/locations/cross-fields", response_class=HTMLResponse)
def cross_fields_view(q: Optional[str] = Query(None)):
"""Show every active cross-field with capacity and location context."""
try:
params = []
where = ["cf.deleted_at IS NULL", "cf.is_active = TRUE", "l.deleted_at IS NULL"]
if q and q.strip():
where.append("(l.name ILIKE %s OR tree.hierarchy_path ILIKE %s OR cf.name ILIKE %s)")
params.extend([f"%{q.strip()}%"] * 3)
cross_fields = execute_query(f"""
WITH RECURSIVE tree AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
UNION ALL
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
WHERE l.deleted_at IS NULL
), port_usage AS (
SELECT p.cross_field_id,
COUNT(*) FILTER (WHERE p.is_active = TRUE) AS total_ports,
COUNT(o.id) FILTER (WHERE p.is_active = TRUE AND o.deleted_at IS NULL) AS assigned_ports
FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id
GROUP BY p.cross_field_id
)
SELECT l.id AS location_id, l.name AS location_name, tree.hierarchy_path,
COUNT(cf.id) AS field_count,
STRING_AGG(cf.name, ', ' ORDER BY cf.display_order, cf.name) AS field_names,
COALESCE(SUM(port_usage.total_ports), 0) AS total_ports,
COALESCE(SUM(port_usage.assigned_ports), 0) AS assigned_ports
FROM locations_cross_fields cf
JOIN locations_locations l ON l.id = cf.location_id
LEFT JOIN tree ON tree.id = l.id
LEFT JOIN port_usage ON port_usage.cross_field_id = cf.id
WHERE {' AND '.join(where)}
GROUP BY l.id, l.name, tree.hierarchy_path
ORDER BY tree.hierarchy_path
""", tuple(params)) or []
return HTMLResponse(render_template(
"modules/locations/templates/cross_fields.html",
cross_fields=cross_fields,
query=q or '',
))
except Exception as exc:
logger.error("Error rendering cross-field overview: %s", exc)
raise HTTPException(status_code=500, detail="Kunne ikke vise krydsfelter")
# ============================================================================
# 5. GET /app/locations/{id} - Detail view (HTML)
# ============================================================================
@router.get("/app/locations/{id}", response_class=HTMLResponse)
@ -607,7 +659,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
wall_outlets = execute_query(
"""
SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, switch_name, switch_port, status, notes, is_active
SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id,
switch_hardware_id, switch_name, switch_port, is_wan, status, notes, is_active
FROM locations_wall_outlets
WHERE location_id = %s AND deleted_at IS NULL
ORDER BY outlet_number
@ -618,7 +671,55 @@ def detail_location_view(id: int = Path(..., gt=0)):
# Render the same physical port map directly under each switch on the location page.
hardware_link_map = {}
hardware_ids = [hw['id'] for hw in (hardware or [])]
uisp_by_hardware_id = {}
if hardware_ids:
uisp_rows = execute_query(
"""SELECT link.hardware_id, d.id, d.external_id, d.name, d.display_name,
d.hostname, d.mac_address, d.serial_number, d.vendor, d.model,
d.platform, d.device_type, d.device_role, d.ip_addresses, d.status,
d.last_seen, d.device_link, d.raw_json, d.synced_at
FROM hardware_uisp_links link
JOIN uisp_devices d ON d.id = link.uisp_device_id
WHERE link.hardware_id = ANY(%s)""",
(hardware_ids,),
) or []
for uisp_device in uisp_rows:
raw = uisp_device.get('raw_json') or {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError):
raw = {}
uisp_device['overview'] = raw.get('overview') if isinstance(raw, dict) else {}
uisp_device['firmware'] = (
(raw.get('firmware') or {})
if isinstance(raw, dict) and isinstance(raw.get('firmware'), dict)
else {}
)
# This object is also included in the page's JavaScript hardware list.
# psycopg returns timestamps as datetime objects, which JSON cannot encode.
for timestamp_key in ('last_seen', 'synced_at'):
timestamp = uisp_device.get(timestamp_key)
if hasattr(timestamp, 'isoformat'):
uisp_device[timestamp_key] = timestamp.isoformat()
uisp_device['live_ports'] = {}
for interface in (raw.get('interfaces') or []) if isinstance(raw, dict) else []:
if not isinstance(interface, dict):
continue
identification = interface.get('identification') or {}
status = interface.get('status') or {}
name = str(identification.get('name') or '').lower()
if not name.startswith(('port', 'eth')):
continue
port_number = name.removeprefix('port').removeprefix('eth')
if not port_number.isdigit():
continue
uisp_device['live_ports'][str(int(port_number))] = {
'plugged': bool(status.get('plugged')),
'status': status.get('status'),
'speed': status.get('currentSpeed') or status.get('speed'),
}
uisp_by_hardware_id[uisp_device['hardware_id']] = uisp_device
hardware_link_rows = execute_query(
"""SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port,
target.brand AS target_brand, target.model AS target_model, target.serial_number AS target_serial,
@ -645,6 +746,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
hardware_link_map[(row['target_hardware_id'], str(row['target_port']))] = reverse_row
for hw in hardware or []:
hw['switch_ports'] = []
hw['uisp_device'] = uisp_by_hardware_id.get(hw['id'])
if str(hw.get('asset_type') or '').lower() != 'netværk':
continue
specs = hw.get('hardware_specs') or {}
@ -664,6 +766,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
'port_number': str(port),
'outlet': linked.get(str(port)),
'hardware_link': hardware_link_map.get((hw['id'], str(port))),
'live': (hw['uisp_device'] or {}).get('live_ports', {}).get(str(port)),
}
for port in range(1, port_count + 1)
]
@ -678,14 +781,27 @@ def detail_location_view(id: int = Path(..., gt=0)):
for cross_field in cross_fields or []:
cross_field["ports"] = execute_query(
"""SELECT p.id, p.port_number, p.port_order, p.is_active,
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
o.id AS outlet_id, o.outlet_number, o.customer_id, o.category,
o.patch_panel, o.patch_port, o.switch_hardware_id, o.switch_name, o.switch_port,
o.status AS outlet_status, o.notes AS outlet_notes, o.is_wan,
l.name AS outlet_location_name
FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
LEFT JOIN locations_locations l ON l.id = o.location_id
WHERE p.cross_field_id = %s ORDER BY p.port_order""",
WHERE p.cross_field_id = %s AND p.is_active = TRUE ORDER BY p.port_order""",
(cross_field["id"],),
) or []
for port in cross_field["ports"]:
linked_uisp = uisp_by_hardware_id.get(port.get("switch_hardware_id")) or {}
port["switch_live"] = (linked_uisp.get("live_ports") or {}).get(str(port.get("switch_port")))
if port.get("is_wan"):
port["smart_state"] = "wan"
elif port.get("outlet_id") and port.get("switch_live") and not port["switch_live"].get("plugged"):
port["smart_state"] = "issue"
elif port.get("outlet_id"):
port["smart_state"] = "assigned"
else:
port["smart_state"] = "free"
audit_log = execute_query(
"""

View File

@ -121,6 +121,7 @@ class WallOutletBase(BaseModel):
switch_hardware_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
is_wan: bool = False
status: str = Field('unknown')
notes: Optional[str] = None
is_active: bool = True
@ -147,6 +148,7 @@ class WallOutletUpdate(BaseModel):
switch_hardware_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
is_wan: Optional[bool] = None
status: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None

View File

@ -0,0 +1,41 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Krydsfelter - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div>
<div class="small text-uppercase text-muted fw-semibold mb-1">Lokaliteter</div>
<h1 class="h3 mb-1"><i class="bi bi-diagram-3 me-2"></i>Krydsfelter</h1>
<p class="text-muted mb-0">Én række pr. X-felt med samlet kapacitet og tilknyttede lokationer.</p>
</div>
<a href="/app/locations" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Lokaliteter</a>
</div>
<div class="card border-0 shadow-sm mb-4"><div class="card-body">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-9"><label class="form-label" for="cross-field-search">Søg</label><input id="cross-field-search" class="form-control" name="q" value="{{ query }}" placeholder="X-felt, underfelt eller lokation"></div>
<div class="col-md-3 d-grid"><button class="btn btn-primary"><i class="bi bi-search me-1"></i>Søg</button></div>
</form>
</div></div>
<div class="card border-0 shadow-sm"><div class="table-responsive"><table class="table align-middle mb-0">
<thead><tr><th>X-felt</th><th>Lokation</th><th>Felter</th><th>Porte</th><th>Ledige</th><th></th></tr></thead>
<tbody>
{% for field in cross_fields %}
<tr>
<td><strong>{{ field.location_name }}</strong></td>
<td><a href="/app/locations/{{ field.location_id }}?tab=cross-field" class="text-decoration-none">{{ field.hierarchy_path or field.location_name }}</a></td>
<td>{{ field.field_count }}<div class="small text-muted text-truncate" style="max-width: 280px;">{{ field.field_names }}</div></td>
<td><strong>{{ field.assigned_ports }}</strong> / {{ field.total_ports }}{% if field.port_label_format == 'paired' %}<div class="small text-muted">A/B-par</div>{% endif %}</td>
<td><span class="badge {% if field.total_ports - field.assigned_ports > 0 %}bg-success{% else %}bg-secondary{% endif %}">{{ field.total_ports - field.assigned_ports }}</span></td>
<td class="text-end"><a href="/app/locations/{{ field.location_id }}?tab=cross-field" class="btn btn-outline-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Åbn og rediger</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-5">Ingen X-felter matcher søgningen.</td></tr>
{% endfor %}
</tbody>
</table></div></div>
</div>
{% endblock %}

View File

@ -7,9 +7,16 @@
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
button.patch-port:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; }
.patch-port.wan { background: #0dcaf0; border-color: #087990; color:#052c34; box-shadow: inset 0 0 0 2px rgba(255,255,255,.45); }
.patch-port.smart-issue { background:#ffc107; border-color:#b58105; color:#332701; animation:smart-port-pulse 1.8s ease-in-out infinite; }
.patch-port.unconfigured { background:#52677d; border-color:#34495e; color:#fff; }
.patch-port.border-success { border-width:4px !important; border-color:#20c997 !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); }
.patch-port.border-danger { border-width:4px !important; border-color:#ff5c6c !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); }
.smart-port-hidden { display:none !important; }
@keyframes smart-port-pulse { 50% { box-shadow:0 0 0 3px rgba(255,193,7,.3); } }
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
@ -810,7 +817,7 @@
{% elif location.wall_outlets %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
{% for outlet in location.wall_outlets %}
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.is_wan %} <span class="badge bg-info text-dark">WAN</span>{% endif %}{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-is-wan="{{ 'true' if outlet.is_wan else 'false' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
{% endfor %}
</tbody></table></div>
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
@ -865,9 +872,22 @@
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4 p-3 rounded border bg-light">
<div class="d-flex flex-wrap gap-2 align-items-center">
<strong class="small">Vis porte:</strong>
<div class="btn-group btn-group-sm" role="group" aria-label="Filtrer porte">
<button type="button" class="btn btn-primary smart-port-filter active" data-filter="all">Alle</button>
<button type="button" class="btn btn-outline-warning smart-port-filter" data-filter="issue">Kun fejl <span class="badge text-bg-warning ms-1" id="smartIssueCount">0</span></button>
<button type="button" class="btn btn-outline-info smart-port-filter" data-filter="wan">WAN</button>
<button type="button" class="btn btn-outline-secondary smart-port-filter" data-filter="free">Ledige</button>
</div>
<span class="small text-muted">Grøn = aktiv · Gul = fejl · Turkis = WAN · Lilla = hardware · Blågrå = ikke konfigureret</span>
</div>
<button type="button" class="btn btn-sm btn-primary" id="openBulkPatchBtn"><i class="bi bi-diagram-3 me-1"></i>Massepatch</button>
</div>
{% for field in location.cross_fields %}
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'wan' if port.is_wan else ('smart-issue' if port.smart_state == 'issue' else ('assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else 'unconfigured'))) %}<button type="button" class="patch-port smart-port {{ port_class }}" data-smart-state="{{ port.smart_state }}" data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}" data-outlet-id="{{ port.outlet_id or '' }}" data-outlet-number="{{ port.outlet_number or '' }}" data-outlet-customer-id="{{ port.customer_id or '' }}" data-outlet-category="{{ port.category or '' }}" data-outlet-panel="{{ port.patch_panel or '' }}" data-outlet-patch-port="{{ port.patch_port or '' }}" data-outlet-switch="{{ port.switch_name or '' }}" data-outlet-switch-port="{{ port.switch_port or '' }}" data-outlet-is-wan="{{ 'true' if port.is_wan else 'false' }}" data-outlet-status="{{ port.outlet_status or '' }}" data-outlet-notes="{{ port.outlet_notes or '' }}" title="{% if port.outlet_id %}{{ port.outlet_location_name }} → {{ port.outlet_number or 'vægstik' }} → {{ field.name }} port {{ port.port_number }}{% if port.switch_name %} → {{ port.switch_name }} port {{ port.switch_port }}{% endif %}{% if port.smart_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %}{% else %}Ikke konfigureret — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet_number %} · {{ port.outlet_number }}{% endif %}</span>{% elif port.smart_state == 'issue' %}<span class="patch-port-outlet">⚠ INTET LINK</span>{% elif port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}</button>{% endfor %}</div></div></div>
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
</div>
</div>
@ -887,10 +907,48 @@
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center gap-3">
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
<div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary text-nowrap">Nr. {{ hw.location_display_order or loop.index }}</span>
<div class="input-group input-group-sm" style="width: 225px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}" aria-label="Rækkefølge for {{ hw.brand }} {{ hw.model }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div>
<span class="badge bg-secondary">{{ hw.status }}</span>
</div>
</div>
{% if hw.uisp_device %}
{% set uisp = hw.uisp_device %}
{% set overview = uisp.overview or {} %}
<div class="mt-3 p-3 rounded border bg-light">
<div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
<div class="row g-2 small">
<div class="col-sm-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp.hostname or uisp.display_name or uisp.name or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Senest set</span><strong>{{ uisp.last_seen or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Firmware</span><strong>{{ uisp.firmware.version or uisp.firmware.name or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Platform / rolle</span><strong>{{ uisp.platform or '—' }}{% if uisp.device_role %} · {{ uisp.device_role }}{% endif %}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">Uptime</span><strong>{{ overview.uptime or overview.serviceUptime or '—' }}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">CPU / RAM</span><strong>{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">Temperatur</span><strong>{{ overview.temperature or '—' }}</strong></div>
<div class="col-sm-2"><span class="text-muted d-block">Synkroniseret</span><strong>{{ uisp.synced_at or '—' }}</strong></div>
</div>
</div>
{% endif %}
{% if hw.switch_ports %}
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</div></details>
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}
{% if port.hardware_link %}
<a href="/hardware/{{ hw.id }}" class="patch-port smart-port text-decoration-none hardware-linked{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="hardware" title="Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %} — klik for hardwaredetaljer">
{% else %}
{% set switch_state = 'wan' if port.outlet and port.outlet.is_wan else ('issue' if (port.outlet and port.live and not port.live.plugged) or (not port.outlet and port.live and port.live.plugged) else ('assigned' if port.outlet else 'free')) %}
<button type="button" class="patch-port smart-port switch-port-action{% if switch_state == 'wan' %} wan{% elif switch_state == 'issue' %} smart-issue{% elif port.outlet %} assigned{% else %} unconfigured{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="{{ switch_state }}" data-switch-name="{{ hw.uisp_device.hostname if hw.uisp_device and hw.uisp_device.hostname else ((hw.brand ~ ' · ' if hw.brand else '') ~ (hw.model or '') ~ (' · ' ~ hw.serial_number if hw.serial_number else '')) }}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.outlet.id if port.outlet else '' }}" data-outlet-number="{{ port.outlet.outlet_number if port.outlet else '' }}" data-outlet-customer-id="{{ port.outlet.customer_id if port.outlet else '' }}" data-outlet-category="{{ port.outlet.category if port.outlet else '' }}" data-outlet-panel="{{ port.outlet.patch_panel if port.outlet else '' }}" data-outlet-patch-port="{{ port.outlet.patch_port if port.outlet else '' }}" data-outlet-cross-field-port-id="{{ port.outlet.cross_field_port_id if port.outlet else '' }}" data-outlet-is-wan="{{ 'true' if port.outlet and port.outlet.is_wan else 'false' }}" data-outlet-status="{{ port.outlet.status if port.outlet else '' }}" data-outlet-notes="{{ port.outlet.notes if port.outlet else '' }}" title="{% if port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% if port.outlet.is_wan %} · WAN{% endif %}{% if switch_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %} — klik for at redigere{% elif switch_state == 'issue' %}UISP ser fysisk link, men porten er ikke registreret{% else %}Ikke konfigureret — klik for at tilknytte vægstik{% endif %}">
{% endif %}
<span>{{ port.port_number }}</span>
{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>
{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>
{% elif port.outlet and port.outlet.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet.outlet_number %} · {{ port.outlet.outlet_number }}{% endif %}</span>
{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>
{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}
{% if port.hardware_link %}</a>{% else %}</button>{% endif %}
{% endfor %}</div></div><div class="form-text mt-2">Klik på en switch-port for at oprette eller redigere tilknytningen. Grøn/rød kant viser live linkstatus fra UISP.</div></details>
{% endif %}
</div>
{% endfor %}
@ -1073,20 +1131,42 @@
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
<div class="modal fade" id="bulkPatchModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><form class="modal-content" id="bulkPatchForm">
<div class="modal-header"><h5 class="modal-title">Massepatch porte</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
<div class="col-md-3"><label class="form-label">Fra krydsfelt-port</label><select class="form-select" id="bulkFromPort" required></select></div>
<div class="col-md-3"><label class="form-label">Til krydsfelt-port</label><select class="form-select" id="bulkToPort" required></select></div>
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
<div class="col-md-3"><label class="form-label">Start switch-port</label><select class="form-select" id="bulkSwitchStart" required></select></div>
<div class="col-md-3"><label class="form-label">WAN-port i området</label><select class="form-select" id="bulkWanPort"><option value="">Ingen WAN-port</option></select></div>
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
</div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary" id="bulkPatchSubmit">Kontrollér og gem</button></div>
</form></div>
</div>
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form id="outletForm"><div class="modal-body">
<input type="hidden" id="outletId"><div class="row g-3">
<div class="col-12"><div class="alert alert-primary py-2 mb-0 small" id="outletConnectionPath"><strong>Kabelsti:</strong> endnu ikke forbundet</div></div>
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
<div class="col-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></div>
<div class="col-12"><label class="form-label" for="outletCustomerSearch">Søg firma</label><input class="form-control mb-2" id="outletCustomerSearch" type="search" placeholder="Skriv firmanavn…"><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Søg og vælg firmaet, som porten er tildelt.</div></div>
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
<div class="col-md-6 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" role="switch" id="outletIsWan"><label class="form-check-label fw-semibold" for="outletIsWan">WAN-forbindelse</label><div class="form-text">WAN-porte vises med turkis farve.</div></div></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
</div>
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
@ -1120,10 +1200,15 @@
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (new URLSearchParams(window.location.search).get('tab') === 'cross-field') {
const crossFieldTab = document.getElementById('crossFieldTab');
if (crossFieldTab) bootstrap.Tab.getOrCreateInstance(crossFieldTab).show();
}
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}';
const locationHardware = {{ location.hardware | tojson }};
const locationWallOutlets = {{ location.wall_outlets | tojson }};
const locationCrossFields = {{ location.cross_fields | tojson }};
const existingContactSearchInput = document.getElementById('existingContactSearch');
const existingContactResultsContainer = document.getElementById('existingContactResults');
const existingContactIdInput = document.getElementById('existingContactId');
@ -1475,6 +1560,8 @@ document.addEventListener('DOMContentLoaded', function() {
const outletValue = (id) => document.getElementById(id).value.trim() || null;
function switchDisplayName(hardware) {
const uispHostname = hardware.uisp_device?.hostname;
if (uispHostname) return uispHostname;
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
}
@ -1487,6 +1574,254 @@ document.addEventListener('DOMContentLoaded', function() {
return Number.isInteger(count) && count > 0 ? count : 0;
}
const smartPorts = Array.from(document.querySelectorAll('.smart-port'));
document.getElementById('smartIssueCount').textContent = String(smartPorts.filter(port => port.dataset.smartState === 'issue').length);
document.querySelectorAll('.smart-port-filter').forEach(button => button.addEventListener('click', () => {
const filter = button.dataset.filter;
smartPorts.forEach(port => port.classList.toggle('smart-port-hidden', filter !== 'all' && port.dataset.smartState !== filter));
document.querySelectorAll('.smart-port-filter').forEach(item => {
item.classList.toggle('active', item === button);
item.classList.toggle('btn-primary', item === button);
if (item !== button) item.classList.remove('btn-primary');
});
}));
const bulkPatchElement = document.getElementById('bulkPatchModal');
const bulkPatchModal = bulkPatchElement ? new bootstrap.Modal(bulkPatchElement) : null;
const bulkFieldSelect = document.getElementById('bulkCrossField');
const bulkSwitchSelect = document.getElementById('bulkSwitch');
const bulkCustomerSelect = document.getElementById('bulkCustomer');
let bulkCustomers = [];
function updateBulkCustomerOptions(query = '') {
const normalizedQuery = query.trim().toLocaleLowerCase('da');
const matches = normalizedQuery
? bulkCustomers.filter(customer =>
String(customer.name || customer.navn || '').toLocaleLowerCase('da').includes(normalizedQuery)
).slice(0, 25)
: [];
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + matches
.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`)
.join('');
}
function selectedBulkField() {
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
}
function selectableBulkFieldPorts(field) {
return (field?.ports || []).filter(port => port.is_active !== false);
}
function selectableSwitchPorts(hardware) {
return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined);
}
function bulkSwitchPortStatus(port) {
if (port?.outlet) {
const outletName = port.outlet.outlet_number || `vægstik #${port.outlet.id}`;
return `konfigureret: ${outletName}${port.outlet.is_wan ? ' (WAN)' : ''}`;
}
if (port?.hardware_link) {
const target = [port.hardware_link.target_brand, port.hardware_link.target_model]
.filter(Boolean).join(' ') || 'hardware';
const targetPort = port.hardware_link.target_port ? ` port ${port.hardware_link.target_port}` : '';
return `konfigureret: ${target}${targetPort}`;
}
return 'ledig';
}
function populateBulkFieldPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
document.getElementById('bulkFromPort').innerHTML = ports
.map((port, index) => `<option value="${index}">${port.port_number}</option>`)
.join('');
document.getElementById('bulkFromPort').value = ports.length ? '0' : '';
populateBulkEndPorts(Math.min(23, ports.length - 1));
}
function populateBulkEndPorts(preferredIndex = null) {
const ports = selectableBulkFieldPorts(selectedBulkField());
const from = Number(document.getElementById('bulkFromPort').value);
const firstIndex = Number.isInteger(from) && from >= 0 ? from : 0;
const endSelect = document.getElementById('bulkToPort');
endSelect.innerHTML = ports
.map((port, index) => ({port, index}))
.filter(item => item.index >= firstIndex)
.map(item => `<option value="${item.index}">${item.port.port_number}</option>`)
.join('');
const requested = Number(preferredIndex);
const target = Number.isInteger(requested) && requested >= firstIndex && requested < ports.length
? requested
: firstIndex;
endSelect.value = ports.length ? String(target) : '';
}
function populateBulkSwitchPorts() {
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const ports = selectableSwitchPorts(hardware);
document.getElementById('bulkSwitchStart').innerHTML = ports
.map((port, index) => `<option value="${index}">Port ${port.port_number} — ${bulkSwitchPortStatus(port)}</option>`)
.join('');
}
function populateBulkWanPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const selectedPorts = Number.isInteger(from) && Number.isInteger(to) && to >= from
? ports.slice(from, to + 1)
: [];
const wanSelect = document.getElementById('bulkWanPort');
const previous = wanSelect.value;
wanSelect.innerHTML = '<option value="">Ingen WAN-port</option>' + selectedPorts
.map(port => `<option value="${port.id}">${port.port_number}</option>`)
.join('');
const existingWan = selectedPorts.find(port => port.is_wan);
wanSelect.value = selectedPorts.some(port => String(port.id) === previous)
? previous
: (existingWan ? String(existingWan.id) : '');
}
function updateBulkPreview() {
const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const fieldPorts = selectableBulkFieldPorts(field);
const switchPorts = selectableSwitchPorts(hardware);
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + count);
const firstPort = fieldPorts[from]?.port_number || '—';
const lastPort = fieldPorts[to]?.port_number || '—';
const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
const configuredSwitchPorts = selectedSwitchPorts.filter(port => port.outlet || port.hardware_link);
const configuredWarning = configuredSwitchPorts.length
? `<div class="text-danger fw-semibold mt-2"><i class="bi bi-exclamation-triangle me-1"></i>${configuredSwitchPorts.length} switch-port(e) i området er allerede konfigureret: ${configuredSwitchPorts.map(port => port.port_number).join(', ')}</div>`
: '';
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}${lastSwitchPort}${configuredWarning}`
: count
? 'Det valgte område går ud over de oprettede switch-porte.'
: 'Vælg et gyldigt portområde.';
}
async function openBulkPatch() {
const fields = (locationCrossFields || []).filter(field => selectableBulkFieldPorts(field).length);
bulkFieldSelect.innerHTML = fields.map(field => {
const ports = selectableBulkFieldPorts(field);
const firstPort = ports[0]?.port_number || '—';
const lastPort = ports[ports.length - 1]?.port_number || '—';
return `<option value="${field.id}">${field.name} · ${firstPort}${lastPort} · ${ports.length} porte</option>`;
}).join('');
const switches = (locationHardware || []).filter(item =>
String(item.asset_type || '').toLowerCase() === 'netværk' && selectableSwitchPorts(item).length
);
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${selectableSwitchPorts(item).length} oprettede porte</option>`).join('');
populateBulkFieldPorts();
populateBulkSwitchPorts();
populateBulkWanPorts();
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
const data = response.ok ? await response.json() : [];
bulkCustomers = Array.isArray(data) ? data : (data.customers || []);
document.getElementById('bulkCustomerSearch').value = '';
updateBulkCustomerOptions();
updateBulkPreview();
bulkPatchModal?.show();
}
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
bulkFieldSelect?.addEventListener('change', () => { populateBulkFieldPorts(); populateBulkWanPorts(); updateBulkPreview(); });
bulkSwitchSelect?.addEventListener('change', () => { populateBulkSwitchPorts(); updateBulkPreview(); });
document.getElementById('bulkFromPort')?.addEventListener('change', () => {
populateBulkEndPorts(Number(document.getElementById('bulkToPort').value));
populateBulkWanPorts();
updateBulkPreview();
});
document.getElementById('bulkToPort')?.addEventListener('change', () => {
populateBulkWanPorts();
updateBulkPreview();
});
document.getElementById('bulkSwitchStart')?.addEventListener('change', updateBulkPreview);
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
updateBulkCustomerOptions(event.target.value);
});
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
event.preventDefault();
const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const fieldPorts = selectableBulkFieldPorts(field);
const switchPorts = selectableSwitchPorts(hardware);
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to >= fieldPorts.length) {
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
return;
}
const ports = fieldPorts.slice(from, to + 1);
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + ports.length);
if (switchStart < 0 || selectedSwitchPorts.length !== ports.length) {
alert('Portområdet går ud over switchens oprettede porte.');
return;
}
const conflicts = ports.map((port, index) => switchPortConflict(
hardware,
switchDisplayName(hardware),
selectedSwitchPorts[index].port_number,
port.outlet_id || null
)).filter(Boolean);
if (conflicts.length) {
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
return;
}
const existingCount = ports.filter(port => port.outlet_id).length;
if (!confirm(`Opret/opdatér ${ports.length} forbindelser?\n\n${existingCount} eksisterende vægstik bliver opdateret.`)) return;
const submit = document.getElementById('bulkPatchSubmit');
submit.disabled = true;
submit.textContent = 'Gemmer…';
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
const wanPortId = document.getElementById('bulkWanPort').value
? Number(document.getElementById('bulkWanPort').value)
: null;
const orderedPorts = [
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) !== wanPortId),
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) === wanPortId)
];
const results = [];
for (const {port, index} of orderedPorts) {
const payload = {
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
customer_id: customerId,
category: port.category || null,
patch_panel: field.name,
patch_port: String(port.port_number),
cross_field_port_id: Number(port.id),
switch_hardware_id: Number(hardware.id),
switch_name: switchDisplayName(hardware),
switch_port: String(selectedSwitchPorts[index].port_number),
is_wan: Number(port.id) === wanPortId,
status: 'active',
notes: port.outlet_notes || null
};
if (!port.outlet_id) payload.location_id = Number(locationId);
const response = await fetch(port.outlet_id ? `/api/v1/locations/outlets/${port.outlet_id}` : '/api/v1/locations/outlets', {
method: port.outlet_id ? 'PATCH' : 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
results.push(response.ok);
}
const failed = results.filter(ok => !ok).length;
if (failed) {
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
}
location.reload();
});
function selectedSwitchHardwareId() {
const selectedName = document.getElementById('outletSwitch').value;
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
@ -1564,20 +1899,46 @@ document.addEventListener('DOMContentLoaded', function() {
async function loadOutletCustomers(selectedCustomerId = null) {
const select = document.getElementById('outletCustomerId');
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
document.getElementById('outletCustomerSearch').value = '';
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
(customers || []).forEach(customer => {
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
if (!response.ok) {
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Kunne ikke indlæse kunder</option>');
return;
}
const data = await response.json();
const customers = Array.isArray(data) ? data : (data.customers || []);
customers.forEach(customer => {
const option = document.createElement('option');
option.value = String(customer.id);
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
option.selected = String(customer.id) === String(selectedCustomerId || '');
select.appendChild(option);
});
if (!customers.length) {
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Ingen kunder fundet</option>');
}
}
document.getElementById('outletCustomerSearch')?.addEventListener('input', (event) => {
const query = event.target.value.trim().toLocaleLowerCase('da');
const select = document.getElementById('outletCustomerId');
Array.from(select.options).forEach((option, index) => {
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
});
const firstMatch = Array.from(select.options).find((option, index) => index > 0 && !option.hidden && !option.disabled);
if (query && firstMatch) select.value = firstMatch.value;
});
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
const isExistingOutlet = Boolean(outlet?.id);
outletModal.show();
try {
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
} catch (error) {
console.error('Kunne ikke indlæse vægstik-data', error);
}
document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('outletNumber').value = outlet?.number || '';
document.getElementById('outletCategory').value = outlet?.category || '';
@ -1585,7 +1946,21 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
document.getElementById('outletIsWan').checked = Boolean(outlet?.isWan);
document.getElementById('outletNotes').value = outlet?.notes || '';
const pathParts = [
outlet?.number || 'Nyt vægstik',
selectedPort ? `${selectedPort.fieldName} port ${selectedPort.portNumber}` : (outlet?.panel && outlet?.patchPort ? `${outlet.panel} port ${outlet.patchPort}` : null),
outlet?.switchName && outlet?.switchPort ? `${outlet.switchName} port ${outlet.switchPort}` : null
].filter(Boolean);
document.getElementById('outletConnectionPath').textContent = `Kabelsti: ${pathParts.join(' → ')}`;
if (outlet?.crossFieldPortId) {
const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(outlet.crossFieldPortId);
if (portSelect.value !== String(outlet.crossFieldPortId)) {
portSelect.insertAdjacentHTML('beforeend', `<option value="${outlet.crossFieldPortId}" selected>Tilknyttet krydsfelt-port</option>`);
}
}
if (selectedPort) {
const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(selectedPort.id);
@ -1595,16 +1970,52 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
}
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
outletModal.show();
document.getElementById('outletModalTitle').textContent = isExistingOutlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !isExistingOutlet);
}
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => {
const selectedPort = {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber};
const outlet = port.dataset.outletId ? {
id: port.dataset.outletId,
number: port.dataset.outletNumber,
customerId: port.dataset.outletCustomerId,
category: port.dataset.outletCategory,
panel: port.dataset.outletPanel,
patchPort: port.dataset.outletPatchPort,
switchName: port.dataset.outletSwitch,
switchPort: port.dataset.outletSwitchPort,
isWan: port.dataset.outletIsWan === 'true',
status: port.dataset.outletStatus,
notes: port.dataset.outletNotes
} : null;
openOutletModal(outlet, selectedPort);
}));
document.querySelectorAll('.switch-port-action').forEach(port => port.addEventListener('click', () => {
const outlet = port.dataset.outletId ? {
id: port.dataset.outletId,
number: port.dataset.outletNumber,
customerId: port.dataset.outletCustomerId,
category: port.dataset.outletCategory,
panel: port.dataset.outletPanel,
patchPort: port.dataset.outletPatchPort,
crossFieldPortId: port.dataset.outletCrossFieldPortId,
switchName: port.dataset.switchName,
switchPort: port.dataset.switchPort,
isWan: port.dataset.outletIsWan === 'true',
status: port.dataset.outletStatus,
notes: port.dataset.outletNotes
} : {
switchName: port.dataset.switchName,
switchPort: port.dataset.switchPort,
isWan: false
};
openOutletModal(outlet);
}));
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort, isWan: btn.dataset.isWan === 'true',
status: btn.dataset.status, notes: btn.dataset.notes
})));
@ -1616,6 +2027,7 @@ document.addEventListener('DOMContentLoaded', function() {
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
is_wan: document.getElementById('outletIsWan').checked,
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());

View File

@ -306,6 +306,9 @@
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-ethernet me-2"></i>Vægstik
</a>
<a href="/app/locations/cross-fields" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Krydsfelter
</a>
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
</div>
</div>

View File

@ -0,0 +1 @@
"""Manual migration centre for CRM subscriptions and imported invoice lines."""

View File

@ -0,0 +1 @@
"""Migration centre backend."""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,641 @@
"""Core repositories and workflows for the manual migration centre."""
from __future__ import annotations
import csv
import hashlib
import io
import json
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from difflib import SequenceMatcher
from typing import Any, Dict, Iterable, List, Optional
import jwt
from fastapi import HTTPException, Request
from psycopg2.extras import Json, RealDictCursor
from app.core.config import settings
from app.core.database import (
execute_query,
execute_query_single,
get_db_connection,
release_db_connection,
)
MUTABLE_LOCK_STATES = {"unlocked", "lock_failed"}
def subscription_like_item_sql(alias: str = "migration_center_session_items") -> str:
"""Fast predicate using the classification refreshed when snapshots change."""
p = f"{alias}." if alias else ""
return f"{p}subscription_like=TRUE"
def refresh_subscription_relevance(session_id: int) -> Dict[str, int]:
"""Classify imported invoice lines once so all views share a fast, consistent filter."""
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=TRUE, subscription_relevance_reason='crm_subscription'
WHERE session_id=%s AND source_system<>'economic'
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=FALSE, subscription_relevance_reason='one_off'
WHERE session_id=%s AND source_system='economic'
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_relevance_reason='excluded_charge'
WHERE session_id=%s AND source_system='economic'
AND (
POSITION('gebyr' IN LOWER(COALESCE(product_name,'')))>0
OR POSITION('fragt' IN LOWER(COALESCE(product_name,'')))>0
OR POSITION('porto' IN LOWER(COALESCE(product_name,'')))>0
)
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=TRUE, subscription_relevance_reason='subscription_keyword'
WHERE session_id=%s AND source_system='economic'
AND subscription_relevance_reason<>'excluded_charge'
AND LOWER(COALESCE(product_name,'')) ~
'(abonnement|subscription|måned|kvartal|årlig|licens|license|fiber|internet|bredbånd|hosting|domæne|domain|cloud|microsoft|office[ ]?365|backup|supportaftale|driftsaftale|udlejning|leje|telefoni|simkort|eset)'
""",
(session_id,), fetch=False,
)
execute_query(
"""
WITH recurring AS (
SELECT
COALESCE(NULLIF(customer_no,''),source_customer_id,customer_name) AS customer_key,
COALESCE(NULLIF(product_code,''),LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g'))) AS product_key
FROM migration_center_session_items
WHERE session_id=%s AND source_system='economic'
AND subscription_relevance_reason<>'excluded_charge'
AND invoice_date >= (DATE_TRUNC('month',CURRENT_DATE)-INTERVAL '12 months')::date
GROUP BY 1,2
HAVING COUNT(DISTINCT invoice_no)>=2
AND COUNT(DISTINCT DATE_TRUNC('month',invoice_date))>=2
)
UPDATE migration_center_session_items item
SET subscription_like=TRUE, subscription_relevance_reason='recurring_invoice'
FROM recurring
WHERE item.session_id=%s AND item.source_system='economic'
AND item.subscription_relevance_reason<>'excluded_charge'
AND COALESCE(NULLIF(item.customer_no,''),item.source_customer_id,item.customer_name)
IS NOT DISTINCT FROM recurring.customer_key
AND COALESCE(NULLIF(item.product_code,''),LOWER(REGEXP_REPLACE(item.product_name,'\\s+',' ','g')))
IS NOT DISTINCT FROM recurring.product_key
""",
(session_id, session_id), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items item
SET subscription_like=TRUE, subscription_relevance_reason='existing_subscription'
WHERE item.session_id=%s AND item.source_system='economic'
AND item.subscription_relevance_reason<>'excluded_charge'
AND EXISTS (
SELECT 1 FROM sag_subscriptions subscription
WHERE subscription.customer_id=item.hub_customer_id
AND subscription.status<>'cancelled'
AND (
LOWER(TRIM(COALESCE(subscription.product_name,'')))=
LOWER(TRIM(COALESCE(item.product_name,'')))
OR ABS(COALESCE(subscription.price,0)-COALESCE(item.amount,0))<=0.01
)
)
""",
(session_id,), fetch=False,
)
row = execute_query_single(
"""
SELECT COUNT(*) FILTER (WHERE subscription_like) AS visible,
COUNT(*) FILTER (WHERE NOT subscription_like) AS hidden
FROM migration_center_session_items WHERE session_id=%s
""",
(session_id,),
)
return {"visible": int(row["visible"]), "hidden": int(row["hidden"])}
def json_value(value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {str(k): json_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [json_value(v) for v in value]
return value
def snapshot_hash(payload: Dict[str, Any]) -> str:
packed = json.dumps(json_value(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(packed.encode("utf-8")).hexdigest()
def user_id(current_user: Dict[str, Any]) -> Optional[int]:
value = current_user.get("id") or current_user.get("user_id")
return int(value) if value is not None else None
def audit(
*,
request: Request,
current_user: Dict[str, Any],
action: str,
entity_type: str,
entity_id: Any = None,
session_id: Optional[int] = None,
item_id: Optional[int] = None,
old_value: Any = None,
new_value: Any = None,
source_hash_value: Optional[str] = None,
success: bool = True,
error_message: Optional[str] = None,
) -> None:
execute_query(
"""
INSERT INTO migration_center_audit_log
(session_id, session_item_id, entity_type, entity_id, action, old_value, new_value,
source_hash, performed_by_user_id, ip_address, success, error_message)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
session_id, item_id, entity_type, str(entity_id) if entity_id is not None else None,
action, Json(json_value(old_value)) if old_value is not None else None,
Json(json_value(new_value)) if new_value is not None else None,
source_hash_value, user_id(current_user),
request.client.host if request.client else None, success, error_message,
),
fetch=False,
)
def ensure_writable(session_id: Optional[int] = None) -> None:
if getattr(settings, "MIGRATION_CENTER_READ_ONLY", False):
raise HTTPException(status_code=423, detail="Migreringscenteret er i read-only tilstand")
if session_id:
session = execute_query_single(
"SELECT read_only, status FROM migration_center_sessions WHERE id = %s", (session_id,)
)
if not session:
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
if session["read_only"] or session["status"] in {"completed", "archived"}:
raise HTTPException(status_code=423, detail="Kontrolsessionen er skrivebeskyttet")
class EconomicSnapshotRepository:
"""Read-only access to the Invoice Error Finder invoice snapshot."""
@staticmethod
def latest_run() -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT r.id, r.source_type, r.started_at, r.completed_at, r.status,
r.records_imported, r.records_failed, COUNT(i.id) AS invoice_count
FROM invoice_error_finder_import_runs r
JOIN invoice_error_finder_economic_invoices i ON i.import_run_id = r.id
WHERE r.source_type = 'economic_invoices'
AND r.status IN ('success', 'partial')
AND r.completed_at IS NOT NULL
GROUP BY r.id
HAVING COUNT(i.id) > 0
ORDER BY r.completed_at DESC, r.id DESC
LIMIT 1
"""
)
@staticmethod
def lines(run_id: int) -> List[Dict[str, Any]]:
# DISTINCT ON makes the source identity stable even if an API endpoint repeats a line.
return execute_query(
"""
SELECT DISTINCT ON (
COALESCE(i.source_invoice_number, i.id::text),
i.source_type,
COALESCE(l.line_number, l.id)
)
i.id AS invoice_id, i.source_invoice_number, i.source_type, i.customer_number,
i.customer_name, i.invoice_date, i.currency, i.source_raw AS invoice_raw,
l.id AS invoice_line_id, l.line_number, l.product_number, l.product_name,
l.description, l.quantity, l.unit_price, l.line_net_amount, l.source_raw AS line_raw
FROM invoice_error_finder_economic_invoices i
JOIN invoice_error_finder_economic_invoice_lines l ON l.invoice_id = i.id
WHERE i.import_run_id = %s
AND i.invoice_date >= (DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '12 months')::date
AND i.invoice_date <= CURRENT_DATE
AND LOWER(
COALESCE(l.product_name,'') || ' ' ||
COALESCE(l.description,'') || ' ' ||
COALESCE(l.product_number,'')
) NOT SIMILAR TO '%%(gebyr|fragt|porto)%%'
ORDER BY
COALESCE(i.source_invoice_number, i.id::text),
i.source_type,
COALESCE(l.line_number, l.id),
l.id DESC
""",
(run_id,),
) or []
def attach_economic_snapshot(session_id: int, run: Dict[str, Any]) -> Dict[str, int]:
"""Attach the selected usable 13-month IEF snapshot to an explicit session."""
counts = {"created": 0, "unchanged": 0}
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
UPDATE migration_center_sessions
SET economic_import_run_id=%s, economic_snapshot_at=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s
""",
(run["id"], run["completed_at"], session_id),
)
for row in EconomicSnapshotRepository.lines(run["id"]):
item = _normalize_economic_line(dict(row))
cursor.execute(
"""
INSERT INTO migration_center_session_items
(session_id, entity_type, source_system, source_record_id, source_customer_id,
customer_no, customer_name, product_code, product_name, amount, quantity,
billing_frequency, period_from, period_to, invoice_no, invoice_date,
source_payload, source_hash)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
RETURNING id
""",
(
session_id, item["entity_type"], item["source_system"], item["source_record_id"],
item["source_customer_id"], item["customer_no"], item["customer_name"],
item["product_code"], item["product_name"], item["amount"], item["quantity"],
item["billing_frequency"], item["period_from"], item["period_to"], item["invoice_no"],
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
),
)
inserted = cursor.fetchone()
counts["created" if inserted else "unchanged"] += 1
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
return counts
def _normalize_economic_line(row: Dict[str, Any]) -> Dict[str, Any]:
source_id = f"{row.get('source_type')}:{row.get('source_invoice_number') or row['invoice_id']}:{row.get('line_number') or row['invoice_line_id']}"
raw = {"invoice": row.get("invoice_raw") or {}, "line": row.get("line_raw") or {}}
normalized = {
"entity_type": "invoice_line",
"source_system": "economic",
"source_record_id": source_id,
"source_customer_id": str(row.get("customer_number") or ""),
"customer_no": str(row.get("customer_number") or ""),
"customer_name": str(row.get("customer_name") or "")[:255] or None,
"product_code": str(row.get("product_number") or "")[:100] or None,
"product_name": str(row.get("product_name") or row.get("description") or "Fakturalinje")[:500],
"amount": row.get("line_net_amount") or 0,
"quantity": row.get("quantity") or 1,
"billing_frequency": None,
"period_from": row.get("invoice_date"),
"period_to": None,
"invoice_no": row.get("source_invoice_number"),
"invoice_date": row.get("invoice_date"),
"source_payload": raw,
}
normalized["source_hash"] = snapshot_hash(normalized)
return normalized
def _customer_candidates(item: Dict[str, Any]) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT id, name, cvr_number, email, email_domain, economic_customer_number
FROM customers
WHERE deleted_at IS NULL
ORDER BY id
"""
) or []
source_no = str(item.get("customer_no") or "").strip().lower()
source_name = str(item.get("customer_name") or "").strip().lower()
source_cvr = str((item.get("source_payload") or {}).get("customer_cvr") or "").replace(" ", "").strip()
candidates = []
for row in rows:
rules: List[str] = []
score = 0.0
if source_no and str(row.get("economic_customer_number") or "").strip().lower() == source_no:
score += 0.75
rules.append("Kundenummer stemmer")
if source_cvr and str(row.get("cvr_number") or "").replace(" ", "").strip() == source_cvr:
score += 0.90
rules.append("CVR stemmer")
name_ratio = SequenceMatcher(None, source_name, str(row.get("name") or "").strip().lower()).ratio()
if source_name and name_ratio >= 0.70:
score += min(0.25, name_ratio * 0.25)
rules.append(f"Firmanavn ligner ({round(name_ratio * 100)} %)")
if score:
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
return sorted(candidates, key=lambda value: value["score"], reverse=True)
def _resolve_source_customer(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve authoritative CRM mappings before heuristic customer matching."""
source_id = str(item.get("source_customer_id") or "").strip()
if not source_id:
return None
if item.get("source_system") == "simply":
row = execute_query_single(
"""
SELECT source_customer_name AS customer_name, source_customer_cvr AS cvr,
hub_customer_id
FROM simply_subscription_staging
WHERE source_account_id=%s
ORDER BY (hub_customer_id IS NOT NULL) DESC, updated_at DESC, id DESC
LIMIT 1
""",
(source_id,),
)
if row:
result = dict(row)
result["rule"] = "Eksisterende Simply-kundemapping"
return result
if item.get("source_system") == "vtiger":
row = execute_query_single(
"""
SELECT id AS hub_customer_id, name AS customer_name, cvr_number AS cvr
FROM customers WHERE vtiger_id=%s AND deleted_at IS NULL LIMIT 1
""",
(source_id,),
)
if row:
result = dict(row)
result["rule"] = "Vtiger-konto-id stemmer"
return result
return None
def _subscription_candidates(item: Dict[str, Any], customer_id: Optional[int]) -> List[Dict[str, Any]]:
if not customer_id:
return []
rows = execute_query(
"""
SELECT id, product_name, price, billing_interval, start_date, end_date
FROM sag_subscriptions
WHERE customer_id = %s AND status <> 'cancelled'
ORDER BY updated_at DESC, id DESC
""",
(customer_id,),
) or []
source_name = str(item.get("product_name") or "").strip().lower()
source_amount = Decimal(str(item.get("amount") or 0))
candidates = []
for row in rows:
rules: List[str] = []
score = 0.0
ratio = SequenceMatcher(None, source_name, str(row.get("product_name") or "").strip().lower()).ratio()
if ratio >= 0.55:
score += ratio * 0.55
rules.append(f"Produktnavn ligner ({round(ratio * 100)} %)")
hub_amount = Decimal(str(row.get("price") or 0))
if abs(source_amount - hub_amount) <= Decimal("0.01"):
score += 0.35
rules.append("Beløb stemmer")
elif max(abs(source_amount), Decimal("1")) and abs(source_amount - hub_amount) / max(abs(source_amount), Decimal("1")) <= Decimal("0.10"):
score += 0.15
rules.append("Beløb afviger højst 10 %")
if item.get("billing_frequency") and item["billing_frequency"] == row.get("billing_interval"):
score += 0.10
rules.append("Frekvens stemmer")
if score >= 0.35:
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
return sorted(candidates, key=lambda value: value["score"], reverse=True)
def match_item(item_id: int) -> Dict[str, Any]:
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
if not item:
raise HTTPException(status_code=404, detail="Post blev ikke fundet")
authoritative = _resolve_source_customer(item)
if authoritative:
enriched_payload = dict(item.get("source_payload") or {})
if authoritative.get("cvr"):
enriched_payload["customer_cvr"] = authoritative["cvr"]
execute_query(
"""
UPDATE migration_center_session_items
SET customer_name=COALESCE(NULLIF(%s,''),customer_name),
hub_customer_id=COALESCE(%s,hub_customer_id), source_payload=%s,
updated_at=CURRENT_TIMESTAMP
WHERE id=%s AND lock_status IN ('unlocked','lock_failed')
""",
(
authoritative.get("customer_name"), authoritative.get("hub_customer_id"),
Json(json_value(enriched_payload)), item_id,
), fetch=False,
)
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id=%s", (item_id,))
customer_matches = _customer_candidates(item)
customer_id = item.get("hub_customer_id") or (customer_matches[0]["id"] if customer_matches else None)
sub_matches = _subscription_candidates(item, customer_id)
best = sub_matches[0] if sub_matches else None
explanations = []
if authoritative:
explanations.append(authoritative["rule"])
if customer_matches:
explanations.extend(customer_matches[0]["rules"])
explanations.extend(best["rules"] if best else ["Intet sikkert abonnement-match"])
confidence = best["score"] if best else (customer_matches[0]["score"] * 0.4 if customer_matches else 0)
status = "match_found" if best and confidence >= 0.60 else ("manual_review" if confidence else "no_match")
hub_status = item["hub_status"]
if hub_status == "not_created" and not best:
hub_status = "ready_for_creation" if customer_id else "not_created"
execute_query(
"""
UPDATE migration_center_session_items
SET hub_customer_id = COALESCE(hub_customer_id, %s), suggested_hub_record_id = %s,
match_confidence = %s, match_explanation = %s, match_status = %s,
hub_status = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND lock_status IN ('unlocked','lock_failed')
""",
(customer_id, best["id"] if best else None, confidence, Json(explanations), status, hub_status, item_id),
fetch=False,
)
execute_query(
"DELETE FROM migration_center_matches WHERE session_item_id=%s AND approved IS NULL",
(item_id,), fetch=False,
)
for candidate in customer_matches[:5]:
execute_query(
"""
INSERT INTO migration_center_matches
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
VALUES (%s,'customer',%s,%s,%s)
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
""",
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
fetch=False,
)
for candidate in sub_matches[:5]:
execute_query(
"""
INSERT INTO migration_center_matches
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
VALUES (%s,'subscription',%s,%s,%s)
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
""",
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
fetch=False,
)
return execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
def create_session(name: str, current_user: Dict[str, Any], request: Request) -> Dict[str, Any]:
ensure_writable()
run = EconomicSnapshotRepository.latest_run()
if not run:
raise HTTPException(
status_code=409,
detail="Ingen færdig e-conomic-import findes i Faktura-fejl-finder",
)
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
INSERT INTO migration_center_sessions
(name, status, economic_import_run_id, economic_snapshot_at, created_by_user_id)
VALUES (%s, 'active', %s, %s, %s) RETURNING *
""",
(name.strip(), run["id"], run["completed_at"], user_id(current_user)),
)
session = dict(cursor.fetchone())
for row in EconomicSnapshotRepository.lines(run["id"]):
item = _normalize_economic_line(dict(row))
if item["source_customer_id"]:
customer_payload = {
"source_customer_id": item["source_customer_id"],
"customer_no": item["customer_no"],
"customer_name": item["customer_name"] or item["source_customer_id"],
}
cursor.execute(
"""
INSERT INTO migration_center_source_customers
(source_system, source_customer_id, customer_no, customer_name, raw_payload, snapshot_hash)
VALUES ('economic',%s,%s,%s,%s,%s)
ON CONFLICT (source_system, source_customer_id) DO UPDATE SET
customer_no=EXCLUDED.customer_no, customer_name=EXCLUDED.customer_name,
raw_payload=EXCLUDED.raw_payload, snapshot_hash=EXCLUDED.snapshot_hash,
updated_at=CURRENT_TIMESTAMP
""",
(
item["source_customer_id"], item["customer_no"], customer_payload["customer_name"],
Json(customer_payload), snapshot_hash(customer_payload),
),
)
cursor.execute(
"""
INSERT INTO migration_center_session_items
(session_id, entity_type, source_system, source_record_id, source_customer_id,
customer_no, customer_name, product_code, product_name, amount, quantity,
billing_frequency, period_from, period_to, invoice_no, invoice_date,
source_payload, source_hash)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
""",
(
session["id"], item["entity_type"], item["source_system"], item["source_record_id"],
item["source_customer_id"], item["customer_no"], item["customer_name"],
item["product_code"], item["product_name"], item["amount"], item["quantity"],
item["billing_frequency"], item["period_from"], item["period_to"], item["invoice_no"],
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
audit(
request=request, current_user=current_user, action="session_created",
entity_type="session", entity_id=session["id"], session_id=session["id"], new_value=session,
)
# Matching is deliberately outside the snapshot transaction and can be re-run safely.
ids = execute_query("SELECT id FROM migration_center_session_items WHERE session_id = %s", (session["id"],)) or []
for row in ids:
match_item(int(row["id"]))
refresh_subscription_relevance(int(session["id"]))
return session
def preflight_token(item: Dict[str, Any], current_user: Dict[str, Any]) -> str:
payload = {
"purpose": "migration_center_create",
"item_id": item["id"],
"source_hash": item["source_hash"],
"user_id": user_id(current_user),
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
def verify_preflight(token: str, item: Dict[str, Any], current_user: Dict[str, Any]) -> None:
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=409, detail="Preflight er udløbet eller ugyldig") from exc
if (
payload.get("purpose") != "migration_center_create"
or int(payload.get("item_id", 0)) != int(item["id"])
or payload.get("source_hash") != item["source_hash"]
or int(payload.get("user_id", 0)) != int(user_id(current_user) or 0)
):
raise HTTPException(status_code=409, detail="Kildedata eller bruger er ændret siden preflight")
def report_csv(session_id: int) -> str:
session = execute_query_single("SELECT * FROM migration_center_sessions WHERE id = %s", (session_id,))
if not session:
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
rows = execute_query(
f"""
SELECT entity_type, source_system, source_record_id, invoice_no, customer_no, customer_name,
product_code, product_name, amount, quantity, match_status, approval_status,
hub_status, lock_status, hub_customer_id, hub_sag_id, hub_record_id,
ignore_reason, verified_at, locked_at
FROM migration_center_session_items
WHERE session_id = %s
AND {subscription_like_item_sql()}
ORDER BY id
""",
(session_id,),
) or []
output = io.StringIO()
fields = list(rows[0].keys()) if rows else [
"entity_type", "source_system", "source_record_id", "match_status",
"approval_status", "hub_status", "lock_status",
]
writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({key: json_value(value) for key, value in dict(row).items()})
return output.getvalue()

View File

@ -0,0 +1 @@
"""Migration centre frontend."""

View File

@ -0,0 +1,20 @@
"""Jinja views for the migration centre."""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.core.auth_dependencies import require_permission
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/migration-center", response_class=HTMLResponse)
async def migration_center(
request: Request,
current_user: dict = Depends(require_permission("migration_center.view")),
):
return templates.TemplateResponse(
"modules/migration_center/templates/index.html",
{"request": request, "current_user": current_user},
)

View File

@ -0,0 +1,11 @@
{
"name": "migration_center",
"version": "1.0.0",
"description": "Manuel migrering og kontrol af abonnementer og fakturalinjer.",
"author": "BMC Networks",
"enabled": true,
"dependencies": ["sag", "invoice_error_finder"],
"table_prefix": "migration_center_",
"api_prefix": "/api/v1/migration-center",
"tags": ["Migration", "Subscriptions", "Invoices"]
}

View File

@ -0,0 +1,349 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Migreringscenter - BMC Hub{% endblock %}
{% block content %}
<style>
.mc-shell { --mc-navy:#14243a; --mc-blue:#2563eb; --mc-bg:#f4f7fb; color:#172033; }
.mc-hero { background:linear-gradient(120deg,#14243a,#203e65); color:white; border-radius:18px; padding:24px; }
.mc-stat { border:0; border-radius:14px; box-shadow:0 5px 20px rgba(20,36,58,.07); }
.mc-label { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#728099; font-weight:700; }
.mc-number { font-size:1.65rem; font-weight:750; }
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:white; }
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:#6b778c; background:#f8fafc; }
.mc-table td { vertical-align:middle; }
.mc-product { max-width:300px; }
.mc-badge { border-radius:999px; padding:.34rem .6rem; font-size:.72rem; font-weight:700; display:inline-block; }
.mc-green { background:#dcfce7; color:#166534; } .mc-yellow { background:#fef3c7; color:#92400e; }
.mc-red { background:#fee2e2; color:#991b1b; } .mc-gray { background:#e5e7eb; color:#374151; }
.mc-blue { background:#dbeafe; color:#1e40af; }
.mc-queue { border-left:4px solid var(--mc-blue); }
.mc-source-card { background:#f8fafc; border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
.mc-explanation { margin:0; padding-left:1.1rem; color:#56647a; font-size:.88rem; }
.mc-actions .btn { margin:2px; }
@media(max-width:768px){ .mc-hero{padding:18px}.mc-product{max-width:180px}.mc-actions{min-width:170px} }
</style>
<div class="container-fluid py-4 mc-shell">
<section class="mc-hero mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="text-uppercase small opacity-75 fw-semibold mb-1">Manuel kontrol og overførsel</div>
<h1 class="h3 mb-2">Migreringscenter</h1>
<p class="mb-0 opacity-75">Abonnementer fra CRM og fakturalinjer fra Faktura-fejl-finder. Intet oprettes automatisk.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<select id="sessionSelect" class="form-select" style="min-width:230px" aria-label="Kontrolsession"></select>
<button class="btn btn-light" onclick="createSession()"><i class="bi bi-plus-circle me-1"></i>Ny session</button>
<button class="btn btn-outline-light" onclick="importCrm('vtiger')">Hent Vtiger</button>
<button class="btn btn-outline-light" onclick="importCrm('simply')">Hent Simply</button>
<button class="btn btn-warning" onclick="loadEconomic13Months()">Indlæs 13 mdr. fakturaer</button>
<button class="btn btn-outline-light" onclick="rematchAll()">Match alle igen</button>
<a id="exportLink" class="btn btn-outline-light" href="#"><i class="bi bi-download me-1"></i>CSV</a>
</div>
</div>
<div id="snapshotWarning" class="alert alert-warning mt-3 mb-0 d-none"></div>
</section>
<div id="emptyState" class="card border-0 shadow-sm d-none">
<div class="card-body text-center py-5">
<i class="bi bi-inboxes fs-1 text-muted"></i>
<h2 class="h5 mt-3">Ingen kontrolsession endnu</h2>
<p class="text-muted">Opret en session for at fastlåse seneste e-conomic-grundlag fra Faktura-fejl-finder.</p>
<button class="btn btn-primary" onclick="createSession()">Opret kontrolsession</button>
</div>
</div>
<div id="workspace" class="d-none">
<div class="row g-3 mb-4" id="stats"></div>
<ul class="nav nav-pills mb-3 gap-1" id="mcTabs">
<li class="nav-item"><button class="nav-link active" data-view="all">Dashboard</button></li>
<li class="nav-item"><button class="nav-link" data-view="queue">Arbejdsbakke</button></li>
<li class="nav-item"><button class="nav-link" data-view="subscription">Abonnementer</button></li>
<li class="nav-item"><button class="nav-link" data-view="invoice_line">Fakturaer</button></li>
<li class="nav-item"><button class="nav-link" data-view="invoice_history">13 mdr. historik</button></li>
<li class="nav-item"><button class="nav-link" data-view="subscription_candidates">Mulige abonnementer</button></li>
<li class="nav-item"><button class="nav-link" data-view="deviations">Afvigelser</button></li>
<li class="nav-item"><button class="nav-link" data-view="locked">Låste</button></li>
<li class="nav-item"><button class="nav-link" data-view="audit">Log / historik</button></li>
</ul>
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
<div class="card-body" id="queueContent"></div>
</section>
<section id="listPanel">
<div class="card mc-stat mb-3">
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-lg-3"><input id="searchInput" class="form-control" placeholder="Søg produkt, faktura eller kilde-id"></div>
<div class="col-12 col-lg-3"><select id="companyFilter" class="form-select"><option value="">Alle virksomheder</option></select></div>
<div class="col-6 col-lg-2"><select id="sourceFilter" class="form-select"><option value="">Alle kilder</option><option value="vtiger">Vtiger</option><option value="simply">Simply CRM</option><option value="economic">e-conomic</option></select></div>
<div class="col-6 col-lg-1"><select id="statusFilter" class="form-select"><option value="">Status</option><option value="no_match">Intet match</option><option value="manual_review">Manuel kontrol</option><option value="source_changed">Kilde ændret</option><option value="match_found">Match fundet</option></select></div>
<div class="col-6 col-lg-1"><select id="lockFilter" class="form-select"><option value="">Lås</option><option value="unlocked">Ulåst</option><option value="locking_pending">Låser</option><option value="lock_failed">Låsefejl</option><option value="locked">Låst</option></select></div>
<div class="col-12 col-lg-2"><button class="btn btn-primary w-100" onclick="loadItems(1)">Filtrér</button></div>
</div>
</div>
</div>
<div class="mc-table-wrap">
<div class="table-responsive">
<table class="table table-hover mc-table mb-0">
<thead><tr><th>Dato</th><th>Kunde</th><th>Kilde</th><th>Produkt</th><th>Beløb</th><th>Match</th><th>Workflow</th><th>Hub-ID</th><th>Handling</th></tr></thead>
<tbody id="itemsBody"></tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<small class="text-muted" id="pageInfo"></small>
<div class="btn-group"><button class="btn btn-sm btn-outline-secondary" id="prevPage">Forrige</button><button class="btn btn-sm btn-outline-secondary" id="nextPage">Næste</button></div>
</div>
</div>
</section>
<section id="auditPanel" class="mc-table-wrap d-none">
<div class="table-responsive"><table class="table mc-table mb-0"><thead><tr><th>Tidspunkt</th><th>Bruger</th><th>Handling</th><th>Type</th><th>Reference</th><th>Resultat</th></tr></thead><tbody id="auditBody"></tbody></table></div>
</section>
<section id="historyPanel" class="mc-table-wrap d-none">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between gap-3 align-items-end"><div><h2 class="h5 mb-1" id="historyTitle">Fakturahistorik</h2><p class="text-muted mb-0">Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.</p></div><div style="min-width:300px"><label class="form-label mc-label">Filtrér på virksomhed</label><select id="historyCompanyFilter" class="form-select" onchange="loadInvoiceHistory(view==='subscription_candidates')"><option value="">Alle virksomheder</option></select></div></div>
<div class="table-responsive"><table class="table table-hover mc-table mb-0"><thead><tr><th>Kunde</th><th>Vare</th><th>Fakturaer</th><th>Måneder</th><th>Periode</th><th>Beløb</th><th>Frekvens</th><th>Hub-match</th><th>Handling</th></tr></thead><tbody id="historyBody"></tbody></table></div>
</section>
</div>
</div>
<div class="modal fade" id="actionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><h2 class="modal-title h5" id="modalTitle">Post</h2><button class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer" id="modalFooter"><button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div>
</div></div>
</div>
<script>
const API='/api/v1/migration-center';
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
const modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
async function api(path, options={}){
const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});
if(!res.ok){let d={};try{d=await res.json()}catch{};throw new Error(typeof d.detail==='object'?(d.detail.message||JSON.stringify(d.detail)):(d.detail||`HTTP ${res.status}`))}
return res.headers.get('content-type')?.includes('json')?res.json():res.text();
}
function badge(value){
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
const gray=['locked'];
return `<span class="mc-badge ${gray.includes(value)?'mc-gray':green.includes(value)?'mc-green':red.includes(value)?'mc-red':'mc-yellow'}">${esc((value||'-').replaceAll('_',' '))}</span>`;
}
async function init(){
sessions=await api('/sessions');
const select=document.getElementById('sessionSelect');
select.innerHTML=sessions.map(s=>`<option value="${s.id}">${esc(s.name)} · ${s.treated_count}/${s.item_count}</option>`).join('');
const remembered=Number(localStorage.getItem('migrationCenterSession'));
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
if(!sessionId){document.getElementById('emptyState').classList.remove('d-none');return}
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
document.getElementById('workspace').classList.remove('d-none');
await refresh();
}
async function createSession(){
const name=prompt('Navn på kontrolsession',`Migrering ${new Date().toLocaleDateString('da-DK',{month:'long',year:'numeric'})}`);
if(!name)return;
try{const s=await api('/sessions',{method:'POST',body:JSON.stringify({name})});localStorage.setItem('migrationCenterSession',s.id);location.reload()}catch(e){alert(e.message)}
}
async function importCrm(source){
if(!sessionId)return alert('Opret en session først');
if(!confirm(`Hent et nyt ${source} snapshot ind i denne kontrolsession? Ændrede poster kræver ny kontrol.`))return;
try{const d=await api(`/sessions/${sessionId}/crm-import?source=${source}`,{method:'POST'});alert(`${d.records} poster hentet · ${d.created} nye · ${d.changed} ændrede`);await refresh()}catch(e){alert(e.message)}
}
async function loadEconomic13Months(){
if(!sessionId)return alert('Opret en session først');
if(!confirm('Indlæs de seneste 13 måneders allerede importerede fakturaer fra Faktura-fejl-finder?'))return;
try{const d=await api(`/sessions/${sessionId}/economic-snapshot`,{method:'POST'});alert(`${d.created} fakturalinjer indlæst · ${d.customers_mapped} kundematches`);await refresh()}catch(e){alert(e.message)}
}
async function rematchAll(){
if(!sessionId)return;
try{const d=await api(`/sessions/${sessionId}/rematch`,{method:'POST'});alert(`${d.processed} poster behandlet · ${d.customers_mapped} virksomheder mappet · ${d.matches_found} abonnementmatches`);await refresh()}catch(e){alert(e.message)}
}
async function refresh(){document.getElementById('exportLink').href=`${API}/sessions/${sessionId}/report.csv`;await Promise.all([loadDashboard(),loadCompanyOptions(),loadItems(1)])}
async function loadCompanyOptions(){
const rows=await api(`/sessions/${sessionId}/company-options`);
for(const id of ['companyFilter','historyCompanyFilter']){
const select=document.getElementById(id), selected=select.value;
select.innerHTML='<option value="">Alle virksomheder</option>'+rows.map(r=>`<option value="${esc(r.customer_name)}">${esc(r.customer_name)} (${r.item_count})</option>`).join('');
if([...select.options].some(o=>o.value===selected))select.value=selected;
}
}
async function loadDashboard(){
const d=await api(`/sessions/${sessionId}/dashboard`), c=d.counts;
const defs=[['Poster',c.total,'bi-stack'],['Klar',c.ready,'bi-check2-square'],['Oprettet',c.created,'bi-cloud-check'],['Afvigelser',c.conflicts,'bi-exclamation-triangle'],['Ignoreret',c.ignored,'bi-slash-circle'],['Låst',c.locked,'bi-lock']];
document.getElementById('stats').innerHTML=defs.map(x=>`<div class="col-6 col-lg-2"><div class="card mc-stat h-100"><div class="card-body"><div class="mc-label"><i class="bi ${x[2]} me-1"></i>${x[0]}</div><div class="mc-number">${x[1]||0}</div></div></div></div>`).join('');
const age=(Date.now()-new Date(d.session.economic_snapshot_at))/86400000;
const warn=document.getElementById('snapshotWarning');
if(d.read_only){warn.classList.remove('d-none');warn.textContent='Migreringscenteret er i read-only tilstand. Historik og rapporter er fortsat tilgængelige.'}
else if(age>d.stale_after_days){warn.classList.remove('d-none');warn.innerHTML=`Fakturagrundlaget er ${Math.floor(age)} dage gammelt. <a href="/invoice-error-finder" class="alert-link">Åbn Faktura-fejl-finder</a> før du opretter en ny session.`}else warn.classList.add('d-none');
}
function query(){
const p=new URLSearchParams({page, page_size:50});
const q=document.getElementById('searchInput').value.trim(); if(q)p.set('q',q);
const source=document.getElementById('sourceFilter').value;if(source)p.set('source_system',source);
const company=document.getElementById('companyFilter').value;if(company)p.set('company_name',company);
const status=document.getElementById('statusFilter').value;if(status)p.set('match_status',status);
const lock=document.getElementById('lockFilter').value;if(lock)p.set('lock_status',lock);
if(['subscription','invoice_line'].includes(view))p.set('entity_type',view);
if(view==='deviations')p.set('only_deviations','true');
if(view==='locked')p.set('lock_status','locked');
return p;
}
async function loadItems(p=1){
page=p;
if(view==='queue'){await loadQueue();return}
if(view==='audit'){await loadAudit();return}
if(['invoice_history','subscription_candidates'].includes(view)){await loadInvoiceHistory(view==='subscription_candidates');return}
const d=await api(`/sessions/${sessionId}/items?${query()}`);total=d.total;
document.getElementById('itemsBody').innerHTML=d.items.length?d.items.map(rowHtml).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen poster matcher filtrene</td></tr>`;
const end=Math.min(page*50,total);document.getElementById('pageInfo').textContent=total?`${(page-1)*50+1}-${end} af ${total}`:'0 poster';
document.getElementById('prevPage').disabled=page===1;document.getElementById('nextPage').disabled=end>=total;
}
async function loadInvoiceHistory(onlyCandidates){
const company=document.getElementById('historyCompanyFilter').value;
const d=await api(`/sessions/${sessionId}/invoice-history?only_subscription_candidates=${onlyCandidates?'true':'false'}${company?`&company_name=${encodeURIComponent(company)}`:''}`);
document.getElementById('historyTitle').textContent=onlyCandidates?'Mulige manglende abonnementer':'Fakturahistorik · seneste 13 måneder';
document.getElementById('historyBody').innerHTML=d.groups.length?d.groups.map(g=>`<tr>
<td><strong>${esc(g.customer_name||'Ukendt')}</strong><br><small class="text-muted">${esc(g.customer_no||'')}</small></td>
<td><strong>${esc(g.product_name)}</strong><br><small class="text-muted">${esc(g.product_code||'')}</small></td>
<td>${g.invoice_count}</td><td>${g.invoiced_months} / 13</td><td class="text-nowrap">${esc(g.first_invoice_date)} ${esc(g.last_invoice_date)}</td>
<td>${Number(g.min_amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}${Number(g.max_amount||0)!==Number(g.min_amount||0)?` ${Number(g.max_amount).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}`:''}</td>
<td>${badge(g.suggested_frequency)}</td><td>${g.has_hub_subscription?badge('match_found'):badge('no_match')}</td>
<td>${g.subscription_candidate?`<button class="btn btn-sm btn-primary" onclick="openItem(${g.representative_item_id})">Vurdér / opret abonnement</button>`:'<span class="text-muted">Kontrol</span>'}</td></tr>`).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen fakturagrupper fundet</td></tr>`;
}
function rowHtml(i){
const explanations=(i.match_explanation||[]).map(esc).join(' · ');
return `<tr>
<td class="text-nowrap">${esc(i.invoice_date||i.period_from||'-')}</td>
<td><button class="btn btn-link p-0 text-start" onclick="${i.hub_customer_id?`showCustomer(${i.hub_customer_id})`:'void 0'}"><strong>${esc(i.customer_name||'Ukendt')}</strong></button><br><small class="text-muted">${esc(i.customer_no||'')}</small></td>
<td>${badge(i.source_system)}</td><td class="mc-product"><strong>${esc(i.product_name)}</strong><br><small class="text-muted">${esc(i.product_code||i.invoice_no||'')}</small></td>
<td class="text-nowrap">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td>
<td>${badge(i.match_status)}<br><small class="text-muted" title="${esc(explanations)}">${i.match_confidence!=null?Math.round(i.match_confidence*100)+' %':'-'}</small></td>
<td>${badge(i.hub_status)} ${badge(i.approval_status)} ${badge(i.lock_status)}</td>
<td>${i.hub_record_id?`<a href="${i.hub_sag_id?`/sag/${i.hub_sag_id}/v3`:`/subscriptions/${i.hub_record_id}`}">#${i.hub_record_id}</a>`:'-'}</td>
<td class="mc-actions"><button class="btn btn-sm btn-outline-primary" onclick="openItem(${i.id})">Vis / behandl</button></td></tr>`;
}
function filterCustomer(name){document.getElementById('searchInput').value=name;loadItems(1)}
async function showCustomer(customerId){
try{
const d=await api(`/sessions/${sessionId}/customers/${customerId}`);
document.getElementById('modalTitle').textContent=`Kundeoverblik · ${d.customer.name}`;
document.getElementById('modalBody').innerHTML=`<div class="row g-3 mb-3"><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">CVR</div>${esc(d.customer.cvr_number||'-')}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Migreringsposter</div>${d.items.length}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Hub-abonnementer</div>${d.hub_subscriptions.length}</div></div></div>
<h3 class="h6">Afvigelser og behandling</h3><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Kilde</th><th>Produkt</th><th>Match</th><th>Workflow</th></tr></thead><tbody>${d.items.map(i=>`<tr><td>${esc(i.source_system)}</td><td>${esc(i.product_name)}</td><td>${badge(i.match_status)}</td><td>${badge(i.approval_status)} ${badge(i.lock_status)}</td></tr>`).join('')}</tbody></table></div>
<h3 class="h6 mt-3">Eksisterende poster i Hubben</h3><ul>${d.hub_subscriptions.map(s=>`<li>#${s.id} · ${esc(s.product_name||s.subscription_number)} · ${esc(s.status)}</li>`).join('')||'<li>Ingen</li>'}</ul>`;
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-dark" ${d.can_lock_all?'':'disabled'} onclick="lockCustomer(${customerId})">Lås alt for kunde</button>`;
modal().show();
}catch(e){alert(e.message)}
}
async function lockCustomer(customerId){if(confirm('Lås alle færdigbehandlede poster for kunden?'))await mutate(`/sessions/${sessionId}/customers/${customerId}/lock`)}
async function loadQueue(after){
const d=await api(`/sessions/${sessionId}/queue/next${after?`?after_id=${after}`:''}`);
const panel=document.getElementById('queueContent');
if(!d.item){panel.innerHTML='<div class="text-center py-4"><i class="bi bi-check-circle text-success fs-2"></i><h3 class="h5 mt-2">Arbejdsbakken er tom</h3></div>';return}
const i=d.item; currentItem=i;
panel.innerHTML=`<div class="d-flex justify-content-between align-items-start mb-3"><div><div class="mc-label">Næste manuelle beslutning</div><h2 class="h5 mb-0">${esc(i.customer_name||'Ukendt kunde')}</h2></div>${badge(i.match_status)}</div>
<div class="row g-3"><div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kilde · ${esc(i.source_system)}</div><strong>${esc(i.product_name)}</strong><div>${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</div><small>${esc(i.source_record_id)}</small></div></div>
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Foreslået Hub-match</div><strong>${i.suggested_hub_record_id?'Abonnement #'+i.suggested_hub_record_id:'Intet sikkert match'}</strong><ul class="mc-explanation mt-2">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div></div></div>
<div class="mt-3 d-flex gap-2 flex-wrap"><button class="btn btn-primary" onclick="openItem(${i.id})">Behandl</button><button class="btn btn-outline-secondary" onclick="loadQueue(${i.id})">Spring over</button></div>`;
}
async function loadAudit(){
const rows=await api(`/sessions/${sessionId}/audit`);
document.getElementById('auditBody').innerHTML=rows.map(a=>`<tr><td>${new Date(a.performed_at).toLocaleString('da-DK')}</td><td>${esc(a.performed_by||'System')}</td><td>${esc(a.action)}</td><td>${esc(a.entity_type)}</td><td>${esc(a.entity_id||'')}</td><td>${a.success?badge('verified'):badge('conflict')}</td></tr>`).join('');
}
async function openItem(id){
let context=await api(`/items/${id}/context`).catch(()=>null), i=context?.item;
if(!i)return alert('Posten kunne ikke hentes');
currentItem=i;
document.getElementById('modalTitle').textContent=`${i.customer_name||'Ukendt kunde'} · ${i.product_name}`;
document.getElementById('modalBody').innerHTML=`<div class="row g-3">
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kildedata</div><dl class="row small mb-0"><dt class="col-5">Kilde</dt><dd class="col-7">${esc(i.source_system)}</dd><dt class="col-5">Reference</dt><dd class="col-7 text-break">${esc(i.source_record_id)}</dd><dt class="col-5">Kunde</dt><dd class="col-7">${esc(i.customer_name||'-')}</dd><dt class="col-5">Produkt</dt><dd class="col-7">${esc(i.product_name)}</dd><dt class="col-5">Beløb</dt><dd class="col-7">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</dd></dl></div></div>
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Matchforklaring</div><ul class="mc-explanation">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul><div class="mt-3">${badge(i.match_status)} ${badge(i.hub_status)} ${badge(i.lock_status)}</div></div></div></div>
<div class="col-12"><details><summary class="small fw-semibold">Vis alle rå felter fra den valgte kilde</summary><pre class="small bg-dark text-light rounded p-3 mt-2 overflow-auto" style="max-height:280px">${esc(JSON.stringify(i.source_payload||{},null,2))}</pre></details></div>
<div class="col-12"><hr><div class="d-flex justify-content-between align-items-center mb-2"><h3 class="h5 mb-0">Samlet kundegrundlag</h3><small class="text-muted">e-conomic ${context.counts.economic} · Vtiger ${context.counts.vtiger} · Simply ${context.counts.simply} · Hub ${context.counts.hub_subscriptions}</small></div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#ctxEconomic">e-conomic (${context.counts.economic})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxVtiger">Vtiger (${context.counts.vtiger})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxSimply">Simply (${context.counts.simply})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxHub">Hubben (${context.counts.hub_subscriptions})</button></li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3">
<div class="tab-pane fade show active" id="ctxEconomic">${sourceContextTable(context.sources.economic,'Fakturadato / nr.')}</div>
<div class="tab-pane fade" id="ctxVtiger">${sourceContextTable(context.sources.vtiger,'Start / slut')}</div>
<div class="tab-pane fade" id="ctxSimply">${sourceContextTable(context.sources.simply,'Start / slut')}</div>
<div class="tab-pane fade" id="ctxHub">${hubContext(context.hub)}</div>
</div>
</div>
<hr><div class="row g-2"><div class="col-md-4"><label class="form-label">Hub-kunde ID</label><div class="input-group"><input id="hubCustomerId" class="form-control" type="number" value="${i.hub_customer_id||''}"><button class="btn btn-outline-primary" onclick="linkCustomer()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCustomer()">Opret kunde eksplicit</button></div>
<div class="col-md-4"><label class="form-label">Hub-sag ID</label><div class="input-group"><input id="hubCaseId" class="form-control" type="number" value="${i.hub_sag_id||''}"><button class="btn btn-outline-primary" onclick="linkCase()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCase()">Opret sag eksplicit</button></div>
<div class="col-md-4"><label class="form-label">Eksisterende abonnement ID</label><div class="input-group"><input id="hubSubscriptionId" class="form-control" type="number" value="${i.suggested_hub_record_id||''}"><button class="btn btn-outline-primary" onclick="linkSubscription()">Link</button></div></div></div>
<div class="mt-3"><label class="form-label">Manuel note</label><textarea id="itemNote" class="form-control" rows="2">${esc(i.manual_note||'')}</textarea><button class="btn btn-sm btn-outline-secondary mt-2" onclick="saveNote()">Gem note</button></div>`;
const disabled=['locked','locking_pending'].includes(i.lock_status)?'disabled':'';
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-outline-warning" ${disabled} onclick="ignoreItem()">Ignorér</button><button class="btn btn-outline-success" ${disabled} onclick="verifyItem()">Markér kontrolleret</button><button class="btn btn-outline-dark" ${disabled} onclick="lockItem()">Lås</button><button class="btn btn-primary" ${disabled} onclick="preflightCreate()">Opret i Hubben</button>`;
modal().show();
}
function sourceContextTable(rows,dateLabel){
if(!rows?.length)return '<div class="text-muted py-3">Ingen relevante poster fra denne kilde for kunden.</div>';
return `<div class="table-responsive" style="max-height:420px"><table class="table table-sm align-middle"><thead class="sticky-top bg-white"><tr><th>${dateLabel}</th><th>Vare</th><th>Beløb</th><th>Status</th><th></th></tr></thead><tbody>${rows.map(r=>`<tr class="${r.id===currentItem.id?'table-primary':''}"><td>${esc(r.invoice_date||r.period_from||'-')}<br><small>${esc(r.invoice_no||r.source_record_id)}</small></td><td><strong>${esc(r.product_name)}</strong><br><small>${esc(r.product_code||'')}</small><details><summary class="small text-muted">Alle kildefelter</summary><pre class="small bg-light p-2 mt-1" style="max-width:520px;max-height:220px;overflow:auto">${esc(JSON.stringify(r.source_payload||{},null,2))}</pre></details></td><td class="text-nowrap">${Number(r.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td><td>${badge(r.hub_status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="openItem(${r.id})">Brug som grundlag</button></td></tr>`).join('')}</tbody></table></div>`;
}
function hubContext(hub){
const customer=hub.customer;
return `${customer?`<div class="mc-source-card mb-3"><div class="mc-label">Hub-kunde #${customer.id}</div><strong>${esc(customer.name)}</strong><div class="small">CVR ${esc(customer.cvr_number||'-')} · e-conomic ${esc(customer.economic_customer_number||'-')} · Vtiger ${esc(customer.vtiger_id||'-')}</div></div>`:'<div class="alert alert-warning">Kilden er endnu ikke linket til en Hub-kunde.</div>'}
<h4 class="h6">Eksisterende abonnementer</h4>${hub.subscriptions?.length?`<div class="table-responsive"><table class="table table-sm"><thead><tr><th>ID</th><th>Produkt</th><th>Pris/frekvens</th><th>Status</th><th></th></tr></thead><tbody>${hub.subscriptions.map(s=>`<tr><td>#${s.id}</td><td><strong>${esc(s.product_name||s.subscription_number)}</strong>${(s.lines||[]).map(l=>`<br><small>${esc(l.description)} · ${l.quantity} × ${Number(l.unit_price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</small>`).join('')}</td><td>${Number(s.price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})} / ${esc(s.billing_interval)}</td><td>${badge(s.status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('hubSubscriptionId').value=${s.id}">Vælg til link</button></td></tr>`).join('')}</tbody></table></div>`:'<p class="text-muted">Ingen abonnementer i Hubben.</p>'}
<h4 class="h6 mt-3">Sager</h4><div class="d-flex flex-wrap gap-2">${(hub.cases||[]).map(s=>`<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('hubCaseId').value=${s.id}">#${s.id} ${esc(s.titel)}</button>`).join('')||'<span class="text-muted">Ingen sager</span>'}</div>`;
}
async function mutate(path,body){try{await api(path,{method:'POST',body:body?JSON.stringify(body):undefined});modal().hide();await refresh()}catch(e){alert(e.message)}}
async function linkCustomer(){const id=Number(document.getElementById('hubCustomerId').value);if(id)await mutate(`/items/${currentItem.id}/customer-link`,{hub_id:id})}
async function linkCase(){const id=Number(document.getElementById('hubCaseId').value);if(id)await mutate(`/items/${currentItem.id}/case-link`,{hub_id:id})}
async function linkSubscription(){const id=Number(document.getElementById('hubSubscriptionId').value);if(id)await mutate(`/items/${currentItem.id}/hub-link`,{hub_id:id})}
async function createCustomer(){const name=prompt('Kundenavn',currentItem.customer_name||'');if(!name)return;const cvr=prompt('CVR (valgfri)','');if(!confirm(`Opret kunden "${name}" i Hubben?`))return;await mutate(`/items/${currentItem.id}/customers`,{name,cvr_number:cvr||null,customer_no:currentItem.customer_no||null})}
async function createCase(){const title=prompt('Sagens titel',`Migrering · ${currentItem.product_name}`);if(!title||!confirm(`Opret sagen "${title}"?`))return;await mutate(`/items/${currentItem.id}/cases`,{title,description:`Manuelt oprettet fra migreringscenter, kilde ${currentItem.source_record_id}`})}
async function saveNote(){try{await api(`/items/${currentItem.id}/note`,{method:'PUT',body:JSON.stringify({note:document.getElementById('itemNote').value})});alert('Noten er gemt')}catch(e){alert(e.message)}}
async function ignoreItem(){const reason=prompt('Årsag til ignorering');if(reason)await mutate(`/items/${currentItem.id}/ignore`,{reason})}
async function verifyItem(){if(confirm('Markér posten som kontrolleret?'))await mutate(`/items/${currentItem.id}/verify`)}
async function lockItem(){if(confirm('Lås posten endeligt? Den kan ikke låses op i denne version.'))await mutate(`/items/${currentItem.id}/lock`)}
async function preflightCreate(){
try{
const p=await api(`/items/${currentItem.id}/preflight`);
if(!p.ready)return alert(`Oprettelsen kan ikke gennemføres:\n\n${p.blockers.join('\n')}`);
pendingPreflight=p;
const v=p.preview, frequencyLabels={daily:'Dagligt',biweekly:'Hver 14. dag',monthly:'Månedligt',quarterly:'Kvartalsvist',yearly:'Årligt'};
document.getElementById('modalTitle').textContent='Bekræft oprettelse i Hubben';
document.getElementById('modalBody').innerHTML=`<div class="alert alert-primary"><i class="bi bi-shield-check me-2"></i>Der oprettes kun dette ene kladde-abonnement. Intet andet overføres.</div>
<div class="mc-source-card"><div class="mc-label mb-3">Data der sendes til Hubben</div><dl class="row mb-0">
<dt class="col-sm-4">Kunde</dt><dd class="col-sm-8"><strong>${esc(v.customer||'-')}</strong>${v.customer_no?`<br><small class="text-muted">Kundenr. ${esc(v.customer_no)}</small>`:''}</dd>
<dt class="col-sm-4">Hub-kunde</dt><dd class="col-sm-8">#${esc(v.hub_customer_id)}</dd>
<dt class="col-sm-4">Hub-sag</dt><dd class="col-sm-8">#${esc(v.hub_sag_id)}</dd>
<dt class="col-sm-4">Produkt</dt><dd class="col-sm-8"><strong>${esc(v.product)}</strong>${v.product_code?`<br><small class="text-muted">${esc(v.product_code)}</small>`:''}</dd>
<dt class="col-sm-4">Beløb</dt><dd class="col-sm-8"><strong>${Number(v.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</strong></dd>
<dt class="col-sm-4">Antal</dt><dd class="col-sm-8">${esc(v.quantity||1)}</dd>
<dt class="col-sm-4">Fakturering</dt><dd class="col-sm-8">${esc(frequencyLabels[v.frequency]||v.frequency)}</dd>
<dt class="col-sm-4">Periode</dt><dd class="col-sm-8">${esc(v.start_date||'-')} ${esc(v.end_date||'løbende')}</dd>
<dt class="col-sm-4">Kilde</dt><dd class="col-sm-8">${esc(v.source)} · ${esc(v.source_record_id)}</dd>
</dl></div>
<p class="small text-muted mt-3 mb-0">Abonnementet oprettes som kladde med én varelinje og skal fortsat kontrolleres og låses separat.</p>`;
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" onclick="openItem(${currentItem.id})">Tilbage</button><button class="btn btn-primary" onclick="confirmCreateInHub(this)"><i class="bi bi-check-circle me-1"></i>Bekræft oprettelse</button>`;
}catch(e){alert(e.message)}
}
async function confirmCreateInHub(button){
if(!pendingPreflight)return;
if(button){button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Opretter…'}
try{
await api(`/items/${currentItem.id}/create-in-hub`,{method:'POST',body:JSON.stringify({preflight_token:pendingPreflight.token,idempotency_key:crypto.randomUUID()})});
pendingPreflight=null;modal().hide();await refresh();
}catch(e){if(button)button.disabled=false;alert(e.message)}
}
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>btn.onclick=async()=>{
document.querySelectorAll('#mcTabs .nav-link').forEach(x=>x.classList.remove('active'));btn.classList.add('active');view=btn.dataset.view;
document.getElementById('queuePanel').classList.toggle('d-none',view!=='queue');
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates'].includes(view));
await loadItems(1);
});
document.getElementById('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(1)});
init().catch(e=>{document.getElementById('emptyState').classList.remove('d-none');document.getElementById('emptyState').querySelector('.card-body').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`});
</script>
{% endblock %}

View File

@ -10,11 +10,14 @@ from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service
logger = logging.getLogger(__name__)
router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
# ============================================================================
@ -47,9 +50,10 @@ def _get_user_id_from_request(request: Request) -> int:
class UserNotificationPreferences(BaseModel):
"""User notification preferences"""
notify_mattermost: bool = True
notify_email: bool = False
notify_email: bool = True
notify_frontend: bool = True
email_override: Optional[str] = None
mattermost_username: Optional[str] = None
quiet_hours_enabled: bool = False
quiet_hours_start: Optional[str] = None # HH:MM format
quiet_hours_end: Optional[str] = None # HH:MM format
@ -154,7 +158,8 @@ async def get_user_notification_preferences(request: Request):
query = """
SELECT
notify_mattermost, notify_email, notify_frontend,
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
email_override, mattermost_username,
quiet_hours_enabled, quiet_hours_start, quiet_hours_end
FROM user_notification_preferences
WHERE user_id = %s
"""
@ -165,9 +170,10 @@ async def get_user_notification_preferences(request: Request):
r = result[0]
return UserNotificationPreferences(
notify_mattermost=r.get('notify_mattermost', True),
notify_email=r.get('notify_email', False),
notify_email=r.get('notify_email', True),
notify_frontend=r.get('notify_frontend', True),
email_override=r.get('email_override'),
mattermost_username=r.get('mattermost_username'),
quiet_hours_enabled=r.get('quiet_hours_enabled', False),
quiet_hours_start=r.get('quiet_hours_start'),
quiet_hours_end=r.get('quiet_hours_end')
@ -198,6 +204,7 @@ async def update_user_notification_preferences(
notify_email = %s,
notify_frontend = %s,
email_override = %s,
mattermost_username = %s,
quiet_hours_enabled = %s,
quiet_hours_start = %s,
quiet_hours_end = %s,
@ -211,6 +218,7 @@ async def update_user_notification_preferences(
preferences.notify_email,
preferences.notify_frontend,
preferences.email_override,
preferences.mattermost_username,
preferences.quiet_hours_enabled,
preferences.quiet_hours_start,
preferences.quiet_hours_end,
@ -221,9 +229,10 @@ async def update_user_notification_preferences(
query = """
INSERT INTO user_notification_preferences (
user_id, notify_mattermost, notify_email, notify_frontend,
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
email_override, mattermost_username,
quiet_hours_enabled, quiet_hours_start, quiet_hours_end
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
@ -233,6 +242,7 @@ async def update_user_notification_preferences(
preferences.notify_email,
preferences.notify_frontend,
preferences.email_override,
preferences.mattermost_username,
preferences.quiet_hours_enabled,
preferences.quiet_hours_start,
preferences.quiet_hours_end
@ -250,7 +260,11 @@ async def update_user_notification_preferences(
# Reminder CRUD Endpoints
# ============================================================================
@router.get("/api/v1/sag/{sag_id}/reminders", response_model=List[ReminderResponse])
@router.get(
"/api/v1/sag/{sag_id}/reminders",
response_model=List[ReminderResponse],
dependencies=[Depends(case_read_access)],
)
async def list_sag_reminders(sag_id: int):
"""List all reminders for a case"""
@ -323,7 +337,11 @@ async def list_my_reminders(request: Request):
]
@router.post("/api/v1/sag/{sag_id}/reminders", response_model=ReminderResponse)
@router.post(
"/api/v1/sag/{sag_id}/reminders",
response_model=ReminderResponse,
dependencies=[Depends(case_edit_access)],
)
async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate):
"""Create a new reminder for a case"""
user_id = _get_user_id_from_request(request)
@ -418,9 +436,10 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/api/v1/sag/reminders/{reminder_id}")
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
@router.patch("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate, request: Request):
"""Update a reminder"""
user_id = _get_user_id_from_request(request)
# Build update query dynamically
updates = []
@ -472,10 +491,10 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
query = f"""
UPDATE sag_reminders
SET {', '.join(updates)}
WHERE id = %s
WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
RETURNING id
"""
params.append(user_id)
result = execute_insert(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Reminder not found")
@ -483,30 +502,35 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
logger.info(f"✅ Reminder {reminder_id} updated")
return {"success": True, "message": "Reminder updated"}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/sag/reminders/{reminder_id}")
async def delete_sag_reminder(reminder_id: int):
@router.delete("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
async def delete_sag_reminder(reminder_id: int, request: Request):
"""Soft-delete a reminder"""
user_id = _get_user_id_from_request(request)
try:
query = """
UPDATE sag_reminders
SET deleted_at = CURRENT_TIMESTAMP, is_active = false
WHERE id = %s
WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_insert(query, (reminder_id,))
result = execute_insert(query, (reminder_id, user_id))
if not result:
raise HTTPException(status_code=404, detail="Reminder not found")
logger.info(f"✅ Reminder {reminder_id} deleted")
return {"success": True, "message": "Reminder deleted"}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -11,13 +11,14 @@ from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.services.email_service import EmailService
from app.services.case_analysis_service import CaseAnalysisService
from app.services.ollama_service import ollama_service
@ -31,7 +32,38 @@ import email
from email.header import decode_header
logger = logging.getLogger(__name__)
router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view")
case_create_access = require_any_permission("cases.create", "tickets.create")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
case_delete_access = require_any_permission("cases.delete", "tickets.delete")
async def case_route_access(request: Request, current_user: dict = Depends(get_current_user)) -> dict:
"""Apply read/create/edit/delete access consistently to every case route."""
if current_user.get("is_superadmin"):
return current_user
method = request.method.upper()
normalized_path = request.url.path.rstrip("/")
if method in {"GET", "HEAD", "OPTIONS"}:
required = {"cases.view", "tickets.view"}
elif method == "POST" and normalized_path == "/api/v1/sag":
required = {"cases.create", "tickets.create"}
elif method == "DELETE" and re.fullmatch(r"/api/v1/sag/\d+", normalized_path):
required = {"cases.delete", "tickets.delete"}
else:
required = {"cases.edit", "tickets.edit"}
available = set(current_user.get("permissions") or [])
if available.intersection(required):
return current_user
raise HTTPException(
status_code=403,
detail=f"Missing required permission. Need one of: {', '.join(sorted(required))}",
)
router = APIRouter(dependencies=[Depends(case_route_access)])
def _table_exists(table_name: str) -> bool:
@ -142,7 +174,7 @@ def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Opti
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.replace(tzinfo=None)
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
@ -208,6 +240,38 @@ def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]:
return ", ".join(cleaned)
def _normalize_relation_input(sag_id: int, data: dict) -> tuple[int, str]:
try:
target_id = int(data.get("målsag_id"))
except (AttributeError, TypeError, ValueError):
raise HTTPException(status_code=400, detail="målsag_id must be an integer")
raw_relation_type = str(data.get("relationstype") or "").strip()
relation_aliases = {
"relateret til": "Relateret til",
"relateret_til": "Relateret til",
"afledt af": "Afledt af",
"afledt_af": "Afledt af",
"årsag til": "Årsag til",
"årsag_til": "Årsag til",
"blokkerer": "Blokkerer",
"afhænger af": "afhænger af",
"afhænger_af": "afhænger af",
"undersag": "undersag",
"duplikat": "duplikat",
"forælder": "forælder",
"barn": "barn",
"udfører for": "udfører_for",
"udfører_for": "udfører_for",
}
relation_type = relation_aliases.get(raw_relation_type.casefold())
if not relation_type:
raise HTTPException(status_code=400, detail="Invalid relationstype")
if sag_id == target_id:
raise HTTPException(status_code=400, detail="A case cannot be related to itself")
return target_id, relation_type
def _deferred_status_matches(
deferred_until_status: Optional[str],
previous_status: Optional[str],
@ -926,8 +990,8 @@ async def list_all_sale_items(
logger.error("❌ Error listing sale items: %s", e)
raise HTTPException(status_code=500, detail="Failed to list sale items")
@router.post("/sag")
async def create_sag(data: dict):
@router.post("/sag", dependencies=[Depends(case_create_access)])
async def create_sag(request: Request, data: dict):
"""Create a case and its optional pipeline/order data atomically."""
try:
if not data.get("titel"):
@ -1025,7 +1089,7 @@ async def create_sag(data: dict):
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"),
assigned_group_id, _get_user_id_from_request(request), deadline, deferred_until, data.get("deferred_until_case_id"),
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
)
result = cursor.fetchone()
@ -1471,7 +1535,7 @@ async def delete_todo_step(step_id: int):
logger.error("❌ Error deleting todo step: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id:int}")
@router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)])
async def update_sag(sag_id: int, updates: dict = Body(...)):
"""Update a case."""
try:
@ -1680,7 +1744,7 @@ class BeskrivelsePatch(BaseModel):
beskrivelse: str
@router.patch("/sag/{sag_id}/beskrivelse")
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request):
"""Update case description and store a change history entry."""
try:
@ -1759,7 +1823,7 @@ class PipelineUpdate(BaseModel):
description: Optional[str] = None
@router.patch("/sag/{sag_id}/pipeline")
@router.patch("/sag/{sag_id}/pipeline", dependencies=[Depends(case_edit_access)])
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
"""Update pipeline fields for a case."""
try:
@ -1817,7 +1881,7 @@ async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to update pipeline")
@router.delete("/sag/{sag_id:int}")
@router.delete("/sag/{sag_id:int}", dependencies=[Depends(case_delete_access)])
async def delete_sag(sag_id: int):
"""Soft-delete a case."""
try:
@ -1870,15 +1934,14 @@ async def get_relationer(sag_id: int):
logger.error("❌ Error getting relations: %s", e)
raise HTTPException(status_code=500, detail="Failed to get relations")
@router.post("/sag/{sag_id}/relationer")
@router.post("/sag/{sag_id}/relationer", dependencies=[Depends(case_edit_access)])
async def create_relation(sag_id: int, data: dict):
"""Add a relation to another case."""
try:
if not data.get("målsag_id") or not data.get("relationstype"):
raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
målsag_id = data.get("målsag_id")
relationstype = data.get("relationstype")
målsag_id, relationstype = _normalize_relation_input(sag_id, data)
# Validate both cases exist
check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
@ -1886,6 +1949,20 @@ async def create_relation(sag_id: int, data: dict):
if not check1 or not check2:
raise HTTPException(status_code=404, detail="One or both cases not found")
duplicate = execute_query(
"""
SELECT id
FROM sag_relationer
WHERE kilde_sag_id = %s
AND målsag_id = %s
AND relationstype = %s
AND deleted_at IS NULL
""",
(sag_id, målsag_id, relationstype),
)
if duplicate:
raise HTTPException(status_code=409, detail="Relation already exists")
query = """
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
@ -1904,7 +1981,7 @@ async def create_relation(sag_id: int, data: dict):
logger.error("❌ Error creating relation: %s", e)
raise HTTPException(status_code=500, detail="Failed to create relation")
@router.delete("/sag/{sag_id}/relationer/{relation_id}")
@router.delete("/sag/{sag_id}/relationer/{relation_id}", dependencies=[Depends(case_edit_access)])
async def delete_relation(sag_id: int, relation_id: int):
"""Soft-delete a relation."""
try:
@ -1958,7 +2035,7 @@ async def get_tags(sag_id: int):
logger.error("❌ Error getting tags: %s", e)
raise HTTPException(status_code=500, detail="Failed to get tags")
@router.post("/sag/{sag_id}/tags")
@router.post("/sag/{sag_id}/tags", dependencies=[Depends(case_edit_access)])
async def add_tag(sag_id: int, data: dict):
"""Add a tag to a case."""
try:
@ -1986,7 +2063,7 @@ async def add_tag(sag_id: int, data: dict):
logger.error("❌ Error adding tag: %s", e)
raise HTTPException(status_code=500, detail="Failed to add tag")
@router.delete("/sag/{sag_id}/tags/{tag_id}")
@router.delete("/sag/{sag_id}/tags/{tag_id}", dependencies=[Depends(case_edit_access)])
async def delete_tag(sag_id: int, tag_id: int):
"""Soft-delete a tag."""
try:
@ -4407,15 +4484,20 @@ def _generate_stored_name(filename: str, subdir: str) -> str:
return f"{subdir}/{unique}"
def _resolve_attachment_path(stored_name: str) -> Path:
return UPLOAD_BASE_PATH / stored_name
candidate = (UPLOAD_BASE_PATH / str(stored_name or "")).resolve()
try:
candidate.relative_to(UPLOAD_BASE_PATH)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid attachment path") from exc
return candidate
def _store_upload_file(upload_file: UploadFile, subdir: str):
if not upload_file.filename:
raise HTTPException(400, detail="Filename missing")
ext = Path(upload_file.filename).suffix.lower().lstrip(".")
# Basic check - allow more types for generic files?
# if ext not in ALLOWED_EXTENSIONS: ...
if not ext or ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"File type .{ext or '?'} is not allowed")
upload_file.file.seek(0, os.SEEK_END)
size = upload_file.file.tell()
@ -4462,7 +4544,7 @@ async def list_sag_files(sag_id: int):
logger.error("❌ Error listing files: %s", e)
raise HTTPException(status_code=500, detail="Failed to list files")
@router.post("/sag/{sag_id}/files")
@router.post("/sag/{sag_id}/files", dependencies=[Depends(case_edit_access)])
async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
"""Upload files to a case."""
if not _table_exists("sag_files"):
@ -4473,6 +4555,7 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
raise HTTPException(status_code=404, detail="Case not found")
saved_files = []
errors = []
for file in files:
try:
@ -4488,12 +4571,17 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
saved = result[0]
saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}"
saved_files.append(saved)
except HTTPException:
continue # Skip invalid
except HTTPException as exc:
errors.append({"filename": file.filename, "detail": exc.detail})
except Exception as e:
logger.error(f"Error saving file {file.filename}: {e}")
continue
errors.append({"filename": file.filename, "detail": "Server upload failed"})
if errors:
raise HTTPException(
status_code=400,
detail={"message": "One or more files could not be uploaded", "files": errors, "saved": saved_files},
)
return saved_files
@router.get("/sag/{sag_id}/files/{file_id}")
@ -4582,7 +4670,7 @@ async def preview_sag_pdf_as_image(sag_id: int, file_id: int, page: int = Query(
logger.error("❌ PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e)
raise HTTPException(status_code=500, detail="Could not render PDF preview")
@router.delete("/sag/{sag_id}/files/{file_id}")
@router.delete("/sag/{sag_id}/files/{file_id}", dependencies=[Depends(case_edit_access)])
async def delete_sag_file(sag_id: int, file_id: int):
"""Delete a file."""
if not _table_exists("sag_files"):

View File

@ -1,12 +1,14 @@
import logging
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Request, Depends
from typing import Optional
from app.core.database import execute_query
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")
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int):
@ -21,8 +23,12 @@ async def get_solution(sag_id: int):
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution")
@router.post("/sag/{sag_id}/solution", response_model=Solution)
async def create_solution(sag_id: int, solution: SolutionCreate):
@router.post(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
"""Create a solution for a case."""
try:
# Check if case exists
@ -47,7 +53,7 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
solution.description,
solution.solution_type,
solution.result,
solution.created_by_user_id
getattr(request.state, "user_id", None)
)
result = execute_query(query, params)
@ -61,7 +67,11 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
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)
@router.patch(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution."""
try:

View File

@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app.core.database import execute_query
from app.utils.safe_html import sanitize_safe_html
logger = logging.getLogger(__name__)
router = APIRouter()
@ -76,6 +77,7 @@ def _is_deadline_overdue(deadline_value) -> bool:
# Setup template directory
templates = Jinja2Templates(directory="app")
templates.env.filters["safe_case_html"] = sanitize_safe_html
def _fetch_assignment_users():
@ -194,6 +196,7 @@ def _fetch_closed_case_statuses() -> list[str]:
async def sager_liste(
request: Request,
status: str = Query(None),
priority: str = Query(None),
tag: str = Query(None),
customer_id: str = Query(None),
ansvarlig_bruger_id: str = Query(None),
@ -276,6 +279,7 @@ async def sager_liste(
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_status = str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all":
pass
elif normalized_status:
@ -285,6 +289,11 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses))
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
params.extend(closed_statuses)
if normalized_priority == "urgent":
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
elif normalized_priority:
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
params.append(normalized_priority)
if customer_id_int:
query += " AND s.customer_id = %s"
params.append(customer_id_int)
@ -348,6 +357,11 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses))
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
fallback_params.extend(closed_statuses)
if normalized_priority == "urgent":
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
elif normalized_priority:
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
fallback_params.append(normalized_priority)
if customer_id_int:
fallback_query += " AND s.customer_id = %s"
fallback_params.append(customer_id_int)
@ -427,6 +441,7 @@ async def sager_liste(
"statuses": status_options,
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_priority": normalized_priority,
"current_tag": tag,
"include_deferred": include_deferred,
"toggle_include_deferred_url": toggle_include_deferred_url,
@ -448,6 +463,7 @@ async def sager_liste(
"statuses": _fetch_case_status_options(),
"all_tags": [],
"current_status": status,
"current_priority": str(priority or "").strip().lower(),
"current_tag": tag,
"include_deferred": include_deferred,
"toggle_include_deferred_url": str(request.url),
@ -658,7 +674,34 @@ async def sag_detaljer(request: Request, sag_id: int):
customer = None
hovedkontakt = None
if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s"
customer_query = """
SELECT
c.*,
(vendor_link.vendor_id IS NOT NULL) AS is_vendor,
vendor_link.vendor_id,
vendor_link.vendor_name,
vendor_link.relationship_type AS vendor_relationship_type
FROM customers c
LEFT JOIN LATERAL (
SELECT
cvl.vendor_id,
v.name AS vendor_name,
cvl.relationship_type
FROM customer_vendor_links cvl
JOIN vendors v ON v.id = cvl.vendor_id
WHERE cvl.customer_id = c.id
AND v.is_active IS NOT FALSE
ORDER BY
CASE cvl.relationship_type
WHEN 'supplier' THEN 0
WHEN 'reseller' THEN 1
ELSE 2
END,
cvl.id
LIMIT 1
) vendor_link ON TRUE
WHERE c.id = %s
"""
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]
@ -986,7 +1029,34 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
customer = None
hovedkontakt = None
if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s"
customer_query = """
SELECT
c.*,
(vendor_link.vendor_id IS NOT NULL) AS is_vendor,
vendor_link.vendor_id,
vendor_link.vendor_name,
vendor_link.relationship_type AS vendor_relationship_type
FROM customers c
LEFT JOIN LATERAL (
SELECT
cvl.vendor_id,
v.name AS vendor_name,
cvl.relationship_type
FROM customer_vendor_links cvl
JOIN vendors v ON v.id = cvl.vendor_id
WHERE cvl.customer_id = c.id
AND v.is_active IS NOT FALSE
ORDER BY
CASE cvl.relationship_type
WHEN 'supplier' THEN 0
WHEN 'reseller' THEN 1
ELSE 2
END,
cvl.id
LIMIT 1
) vendor_link ON TRUE
WHERE c.id = %s
"""
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]

View File

@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""Reversibel end-to-end test af Sag-modulets HTTP API.
Eksempel:
BMC_TEST_PASSWORD='...' python app/modules/sag/scripts/sag_module_e2e.py \
--username admin --customer-id 1
Alternativt kan et eksisterende JWT angives via BMC_TEST_TOKEN eller --token.
Testen opretter to tydeligt navngivne testsager og sletter dem igen i finally.
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
import time
import uuid
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@dataclass
class Result:
name: str
status: str
detail: str = ""
duration_ms: int = 0
class ApiError(RuntimeError):
def __init__(self, method: str, path: str, status: int, body: Any):
super().__init__(f"{method} {path} -> HTTP {status}: {body}")
self.status = status
self.body = body
class SagE2E:
def __init__(self, args: argparse.Namespace):
self.args = args
self.base = args.base_url.rstrip("/")
self.token = args.token or os.getenv("BMC_TEST_TOKEN")
self.results: list[Result] = []
self.created_cases: list[int] = []
self.cleanup: list[tuple[str, Callable[[], Any]]] = []
self.run_id = f"{datetime.now():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:6]}"
self.marker = f"SAG-E2E-{self.run_id}"
def request(
self,
method: str,
path: str,
data: Any = None,
*,
expected: tuple[int, ...] = (200,),
headers: dict[str, str] | None = None,
raw: bool = False,
) -> Any:
request_headers = {"Accept": "application/json"}
if self.token:
request_headers["Authorization"] = f"Bearer {self.token}"
if headers:
request_headers.update(headers)
body = data
if data is not None and not isinstance(data, bytes):
body = json.dumps(data).encode()
request_headers["Content-Type"] = "application/json"
req = Request(self.base + path, data=body, headers=request_headers, method=method)
try:
with urlopen(req, timeout=self.args.timeout) as response:
payload = response.read()
if response.status not in expected:
raise ApiError(method, path, response.status, payload.decode(errors="replace"))
if raw:
return payload
if not payload:
return None
content_type = response.headers.get("Content-Type", "")
return json.loads(payload) if "json" in content_type else payload.decode(errors="replace")
except HTTPError as exc:
payload = exc.read().decode(errors="replace")
try:
payload = json.loads(payload)
except json.JSONDecodeError:
pass
if exc.code in expected:
return payload
raise ApiError(method, path, exc.code, payload) from exc
except URLError as exc:
raise RuntimeError(f"Kan ikke forbinde til {self.base}: {exc.reason}") from exc
def check(self, name: str, operation: Callable[[], Any]) -> Any:
started = time.monotonic()
try:
value = operation()
elapsed = int((time.monotonic() - started) * 1000)
self.results.append(Result(name, "PASS", duration_ms=elapsed))
print(f"PASS {name} ({elapsed} ms)")
return value
except Exception as exc:
elapsed = int((time.monotonic() - started) * 1000)
detail = str(exc).replace("\n", " ")[:800]
self.results.append(Result(name, "FAIL", detail, elapsed))
print(f"FAIL {name}: {detail}")
return None
def skip(self, name: str, reason: str) -> None:
self.results.append(Result(name, "SKIP", reason))
print(f"SKIP {name}: {reason}")
def authenticate(self) -> dict[str, Any]:
if not self.token:
if not self.args.username:
raise RuntimeError("Angiv --token/BMC_TEST_TOKEN eller --username.")
password = self.args.password or os.getenv("BMC_TEST_PASSWORD")
if not password and sys.stdin.isatty():
password = getpass.getpass("Adgangskode: ")
if not password:
raise RuntimeError("Angiv --password eller BMC_TEST_PASSWORD.")
payload = {"username": self.args.username, "password": password}
if self.args.otp:
payload["otp_code"] = self.args.otp
response = self.request("POST", "/api/v1/auth/login", payload)
self.token = response.get("access_token")
if not self.token:
raise RuntimeError("Login returnerede intet access_token.")
me = self.request("GET", "/api/v1/auth/me")
print(f"Bruger: {me.get('full_name') or me.get('username') or me.get('user_id')}")
return me
def resolve_customer_id(self) -> int:
if self.args.customer_id:
return self.args.customer_id
customers = self.request("GET", "/api/v1/customers?limit=1&is_active=true")
rows = customers.get("customers", []) if isinstance(customers, dict) else customers
if not rows:
raise RuntimeError("Ingen aktiv kunde fundet. Angiv --customer-id.")
return int(rows[0]["id"])
@staticmethod
def require(value: Any, message: str) -> Any:
if not value:
raise AssertionError(message)
return value
def create_case(self, customer_id: int, suffix: str) -> dict[str, Any]:
case = self.request(
"POST",
"/api/v1/sag",
{
"titel": f"[{self.marker}] {suffix}",
"beskrivelse": "Automatisk testsag. Må slettes.",
"customer_id": customer_id,
"type": "ticket",
"status": "åben",
},
expected=(200, 201),
)
case_id = int(case["id"])
self.created_cases.append(case_id)
return case
def multipart_file(
self, field: str, filename: str, content: bytes, media_type: str
) -> tuple[bytes, str]:
boundary = f"----SagE2E{uuid.uuid4().hex}"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
f"Content-Type: {media_type}\r\n\r\n"
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
return body, f"multipart/form-data; boundary={boundary}"
def run(self) -> None:
self.check("Serverens health endpoint", lambda: self.request("GET", "/health"))
if self.check("Login og aktuel bruger", self.authenticate) is None:
return
customer_id = self.check("Find testkunde", self.resolve_customer_id)
if customer_id is None:
return
parent = self.check("Opret hovedsag", lambda: self.create_case(customer_id, "Hovedsag"))
child = self.check("Opret undersag", lambda: self.create_case(customer_id, "Undersag"))
if not parent or not child:
return
parent_id, child_id = int(parent["id"]), int(child["id"])
self.check("Hent sag", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}").get("id") == parent_id,
"Forkert sag returneret",
))
self.check("Sag vises i sagslisten", lambda: self.require(
any(int(row["id"]) == parent_id for row in self.request(
"GET", f"/api/v1/sag?search={self.marker}"
)),
"Testsagen blev ikke fundet i listen",
))
self.check("Redigér sag", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/{parent_id}", {
"titel": f"[{self.marker}] Hovedsag redigeret",
"status": "under behandling",
"deadline": (datetime.now(timezone.utc) + timedelta(days=14)).isoformat(),
}).get("id") == parent_id,
"Sag blev ikke opdateret",
))
self.check("Gem beskrivelse", lambda: self.request(
"PATCH", f"/api/v1/sag/{parent_id}/beskrivelse",
{"beskrivelse": "<p>Sikker <strong>HTML</strong></p><script>nope()</script>"},
))
self.check("Beskrivelseshistorik", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}/beskrivelse/history"),
"Historikken er tom",
))
self.check("Hent modulindstillinger", lambda: self.request(
"GET", f"/api/v1/sag/{parent_id}/modules"
))
comment = self.check("Opret kommentar", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/kommentarer",
{"indhold": f"Kommentar {self.marker}", "er_intern": True},
expected=(200, 201),
))
self.check("Læs kommentarer", lambda: self.require(
any(self.marker in str(row.get("indhold", "")) for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/kommentarer"
)),
"Kommentaren blev ikke fundet",
))
self.check("Læs tidslinje", lambda: self.request(
"GET", f"/api/v1/sag/{parent_id}/timeline"
))
todo = self.check("Opret todo", lambda: self.request(
"POST", f"/api/v1/sag/{child_id}/todo-steps",
{"title": f"Næste handling {self.marker}", "description": "E2E", "due_date": None},
expected=(200, 201),
))
if todo:
todo_id = int(todo["id"])
self.check("Markér todo som næste", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_next": True}).get("is_next"),
"Todo er ikke markeret som næste",
))
self.check("Afslut todo", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_done": True}).get("is_done"),
"Todo er ikke afsluttet",
))
self.check("Slet todo", lambda: self.request(
"DELETE", f"/api/v1/sag/todo-steps/{todo_id}"
))
relation = self.check("Opret logisk undersag-relation", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/relationer",
{"målsag_id": child_id, "relationstype": "undersag"},
expected=(200, 201),
))
if relation:
relation_id = int(relation["id"])
self.check("Læs relationer", lambda: self.require(
any(int(row["id"]) == relation_id for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/relationer"
)),
"Relationen blev ikke fundet",
))
self.check("Afvis dubletrelation", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/relationer",
{"målsag_id": child_id, "relationstype": "undersag"},
expected=(409,),
))
self.check("Slet relation", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/relationer/{relation_id}"
))
tag = self.check("Opret tag", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/tags", {"tag_navn": self.marker},
expected=(200, 201),
))
if tag:
self.check("Læs tags", lambda: self.require(
any(row.get("tag_navn") == self.marker for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/tags"
)), "Tag blev ikke fundet",
))
self.check("Slet tag", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/tags/{tag['id']}"
))
buzz = self.check("Opret buzzword", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/buzzwords",
{"buzzword": self.marker.lower()}, expected=(200, 201),
))
if buzz:
self.check("Global søgning finder buzzword", lambda: self.require(
any(int(row.get("id", -1)) == parent_id for row in self.request(
"GET", "/api/v1/search/sag?" + urlencode({"q": self.marker.lower()})
)), "Global søgning fandt ikke sagen via buzzword",
))
self.check("Slet buzzword-link", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/buzzwords/{buzz['buzzword_id']}"
))
item = self.check("Opret salgs-/ordrelinje", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/sale-items",
{"type": "sale", "description": self.marker, "quantity": 1,
"unit_price": 125, "amount": 125, "currency": "DKK", "status": "draft"},
expected=(200, 201),
))
if item:
item_id = int(item["id"])
self.check("Redigér salgs-/ordrelinje", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/{parent_id}/sale-items/{item_id}",
{"status": "confirmed", "amount": 150}).get("status") == "confirmed",
"Linjen blev ikke bekræftet",
))
self.check("Slet salgs-/ordrelinje", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/sale-items/{item_id}"
))
# Minimal gyldig 1x1 PNG; PNG er blandt Sag-modulets tilladte filtyper.
file_content = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c6360000000020001e221bc330000000049454e44ae426082"
)
body, content_type = self.multipart_file(
"files", f"{self.marker}.png", file_content, "image/png"
)
uploaded = self.check("Upload fil", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/files", body,
headers={"Content-Type": content_type}, expected=(200, 201),
))
if uploaded:
file_id = int(uploaded[0]["id"])
self.check("Download fil og kontrollér indhold", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}/files/{file_id}", raw=True) == file_content,
"Downloadet fil har forkert indhold",
))
self.check("Slet fil", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/files/{file_id}"
))
for name, reason in (
("Mailafsendelse og mail-link", "kræver en rigtig mailkonto/mail-ID"),
("AnyDesk-forbindelse", "kræver installeret klient og gyldigt AnyDesk-ID"),
("Direkte labelprint", "kræver fysisk printer"),
("Arbejdsseddel og underskrift", "kræver aktiv work-order/token"),
("Faktura-/økonomisynkronisering", "må ikke skrive i eksternt økonomisystem fra E2E"),
):
self.skip(name, reason)
def cleanup_all(self) -> None:
if self.args.keep_data:
print("Beholder testdata (--keep-data).")
return
for case_id in reversed(self.created_cases):
try:
self.request("DELETE", f"/api/v1/sag/{case_id}", expected=(200, 204, 404))
print(f"CLEAN Sag #{case_id}")
except Exception as exc:
self.results.append(Result(f"Oprydning af sag #{case_id}", "FAIL", str(exc)))
print(f"FAIL Oprydning af sag #{case_id}: {exc}")
def write_report(self) -> None:
counts = {status: sum(r.status == status for r in self.results)
for status in ("PASS", "FAIL", "SKIP")}
report = {
"run_id": self.run_id,
"base_url": self.base,
"created_at": datetime.now(timezone.utc).isoformat(),
"summary": counts,
"results": [asdict(result) for result in self.results],
}
if self.args.json_report:
path = Path(self.args.json_report)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"Rapport: {path.resolve()}")
print(f"\nResultat: {counts['PASS']} PASS, {counts['FAIL']} FAIL, {counts['SKIP']} SKIP")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Reversibel E2E-test af BMC Hub Sag-modulet")
parser.add_argument("--base-url", default=os.getenv("BMC_BASE_URL", "http://127.0.0.1:8001"))
parser.add_argument("--token", help="JWT; alternativt BMC_TEST_TOKEN")
parser.add_argument("--username", help="Loginbrugernavn")
parser.add_argument("--password", help="Frarådes i shellhistorik; brug BMC_TEST_PASSWORD")
parser.add_argument("--otp", help="2FA-kode hvis påkrævet")
parser.add_argument("--customer-id", type=int, help="Kunde til midlertidige testsager")
parser.add_argument("--timeout", type=float, default=20.0)
parser.add_argument("--keep-data", action="store_true", help="Behold testsager ved fejlsøgning")
parser.add_argument("--json-report", default="test-results/sag-e2e.json")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
suite = SagE2E(parse_args(argv))
print(f"Sag E2E {suite.run_id} mod {suite.base}")
try:
suite.run()
except KeyboardInterrupt:
print("\nAfbrudt.")
except Exception as exc:
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
print(f"FAIL Testkørsel: {exc}")
finally:
suite.cleanup_all()
suite.write_report()
return 1 if any(result.status == "FAIL" for result in suite.results) else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -40,13 +40,27 @@ class RelationService:
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"""
SELECT
id,
titel,
status,
template_key,
COALESCE(template_key, 'ticket') AS type
FROM sag_sager
WHERE id IN ({placeholders})
s.id,
s.titel,
s.status,
s.template_key,
COALESCE(s.template_key, 'ticket') AS type,
next_todo.title AS next_todo_title,
next_todo.due_date AS next_todo_due_date
FROM sag_sager s
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
ORDER BY
COALESCE(t.is_next, FALSE) DESC,
t.due_date ASC NULLS LAST,
t.id ASC
LIMIT 1
) next_todo ON TRUE
WHERE s.id IN ({placeholders})
"""
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
@ -73,6 +87,10 @@ class RelationService:
return m, k # m is parent of k
if rtype_lower in ['årsag til', 'cause of']:
return k, m # k is parent of m
if rtype_lower in ['afhænger af', 'depends on']:
return m, k # dependency is parent of the dependent case
if rtype_lower in ['undersag', 'subcase']:
return k, m # source is the parent case
# Default: k is "related" to m, treat as child for visualization if k is current root context
# But here we build a directed graph.
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A

View File

@ -1020,7 +1020,8 @@
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
abonnement: '🔁 Abonnement'
};
function updateCaseTypeSections() {
@ -1109,6 +1110,7 @@
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
@ -1203,7 +1205,6 @@
customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
created_by_user_id: 1, // HARDCODED for now, should come from auth
deadline: document.getElementById('deadline').value || null
};

File diff suppressed because it is too large Load Diff

View File

@ -198,6 +198,7 @@
<option value="ordre" {% if (case.template_key or case.type) == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
<option value="projekt" {% if (case.template_key or case.type) == 'projekt' %}selected{% endif %}>📁 Projekt</option>
<option value="service" {% if (case.template_key or case.type) == 'service' %}selected{% endif %}>🛠️ Service</option>
<option value="abonnement" {% if (case.template_key or case.type) == 'abonnement' %}selected{% endif %}>🔁 Abonnement</option>
</select>
</div>
@ -260,6 +261,7 @@
if (!Array.isArray(types) || types.length === 0) return;
const typeSet = new Set(types);
typeSet.add('abonnement');
if (currentType) {
typeSet.add(currentType);
}

View File

@ -1230,7 +1230,7 @@
const options = Array.from(typeFilter.options || []);
if (options.length === 0) {
typeFilterCheckboxList.innerHTML = '<span class="type-filter-empty">Ingen typer fundet</span>';
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgaengelige';
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgængelige';
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
return;
}

View File

@ -190,7 +190,7 @@ let currentCardStatus = '';
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];

View File

@ -107,7 +107,7 @@
</div>
<div class="mb-3">
<label class="form-label">Note</label>
<input type="text" class="form-control" id="priceNote" placeholder="Aarsag til prisændring">
<input type="text" class="form-control" id="priceNote" placeholder="Årsag til prisændring">
</div>
<div class="mb-3">
<label class="form-label">Opdateret af</label>
@ -117,24 +117,24 @@
<div class="small mt-3" id="priceUpdateMessage"></div>
</div>
<div class="product-card mt-3">
<h5 class="mb-3">Opdater leverandoer</h5>
<h5 class="mb-3">Opdater leverandør</h5>
<div class="mb-3">
<label class="form-label">Leverandoer</label>
<input type="text" class="form-control" id="supplierName" placeholder="Leverandoer navn">
<label class="form-label">Leverandør</label>
<input type="text" class="form-control" id="supplierName" placeholder="Leverandørnavn">
</div>
<div class="mb-3">
<label class="form-label">Leverandoer pris</label>
<label class="form-label">Leverandørpris</label>
<input type="number" class="form-control" id="supplierPrice" step="0.01" min="0">
</div>
<div class="mb-3">
<label class="form-label">Note</label>
<input type="text" class="form-control" id="supplierNote" placeholder="Aarsag til ændring">
<input type="text" class="form-control" id="supplierNote" placeholder="Årsag til ændring">
</div>
<div class="mb-3">
<label class="form-label">Opdateret af</label>
<input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn">
</div>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandoer</button>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandør</button>
<div class="small mt-3" id="supplierUpdateMessage"></div>
</div>
</div>
@ -144,7 +144,7 @@
<div class="table-responsive">
<table class="table table-sm product-table mb-0">
<tbody id="productInfoBody">
<tr><td class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -164,7 +164,7 @@
</tr>
</thead>
<tbody id="priceHistoryBody">
<tr><td colspan="6" class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td colspan="6" class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -186,7 +186,7 @@
</tr>
</thead>
<tbody id="salesHistoryBody">
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td colspan="7" class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -218,7 +218,7 @@
</tr>
</thead>
<tbody id="supplierListBody">
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td colspan="7" class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -256,7 +256,7 @@
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilfoej/Opdater</button>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
</div>
<div class="col-12">
<div class="small" id="supplierListMessage"></div>
@ -356,9 +356,9 @@ function renderProductInfo(product) {
['Status', product.status],
['SKU intern', product.sku_internal],
['Producent', product.manufacturer],
['Leverandoer', product.supplier_name],
['Leverandoer SKU', product.supplier_sku],
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
['Leverandør', product.supplier_name],
['Leverandør-SKU', product.supplier_sku],
['Leverandørpris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : null],
@ -399,8 +399,8 @@ async function loadProductDetail() {
document.getElementById('productPrice').textContent = product.sales_price != null ? formatCurrency(product.sales_price) : '-';
document.getElementById('productSku').textContent = product.sku_internal || '-';
document.getElementById('productSupplierPrice').textContent = product.supplier_price != null
? `Leverandoer: ${formatCurrency(product.supplier_price)}`
: 'Leverandoer pris: -';
? `Leverandør: ${formatCurrency(product.supplier_price)}`
: 'Leverandørpris: -';
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
document.getElementById('supplierName').value = product.supplier_name || '';
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
@ -409,7 +409,7 @@ async function loadProductDetail() {
renderProductInfo(product);
prefillSupplierSku(product);
} catch (e) {
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
setMessage(e.message || 'Fejl ved indlæsning', 'text-danger');
}
}
@ -426,7 +426,7 @@ async function loadPriceHistory() {
tbody.innerHTML = history.map(entry => `
<tr>
<td>${formatDate(entry.changed_at)}</td>
<td>${entry.price_type === 'supplier_price' ? 'Leverandoer' : 'Salgspris'}</td>
<td>${entry.price_type === 'supplier_price' ? 'Leverandør' : 'Salgspris'}</td>
<td>${entry.old_price != null ? formatCurrency(entry.old_price) : '-'}</td>
<td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td>
<td>${escapeHtml(entry.note || '-')}</td>
@ -693,10 +693,10 @@ async function submitSupplierUpdate() {
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Leverandoer opdatering fejlede');
throw new Error(error.detail || 'Opdatering af leverandør fejlede');
}
await res.json();
setSupplierMessage('Leverandoer opdateret', 'text-success');
setSupplierMessage('Leverandør opdateret', 'text-success');
await loadProductDetail();
await loadPriceHistory();
} catch (e) {

View File

@ -210,7 +210,7 @@
<div class="products-search">
<div class="input-group" style="min-width: 260px;">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter eller EAN/strengkode...">
<input type="text" class="form-control" id="localSearchQuery" placeholder="Søg lokale produkter eller EAN/stregkode...">
</div>
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
<option value="active" selected>Aktiv</option>
@ -232,10 +232,10 @@
<option value="false">Nej</option>
</select>
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
<i class="bi bi-search"></i> Soeg
<i class="bi bi-search"></i> Søg
</button>
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)
<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)
</button>
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
<i class="bi bi-x"></i> Nulstil
@ -256,7 +256,7 @@
<span class="text-muted small" id="localProductsPageInfo"></span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" id="localPrevPage" onclick="changeLocalPage(-1)">Forrige</button>
<button class="btn btn-outline-secondary" id="localNextPage" onclick="changeLocalPage(1)">Naeste</button>
<button class="btn btn-outline-secondary" id="localNextPage" onclick="changeLocalPage(1)">Næste</button>
</div>
</div>
</div>
@ -277,7 +277,7 @@
<tbody id="productsBody">
<tr>
<td colspan="6" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
</td>
</tr>
</tbody>
@ -298,14 +298,14 @@
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-6">
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Soeg efter produktnavn...">
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Søg efter produktnavn...">
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Supplier code">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" onclick="searchGatewayProducts(true)">
<i class="bi bi-search"></i> Soeg
<i class="bi bi-search"></i> Søg
</button>
</div>
<div class="col-md-4">
@ -317,7 +317,7 @@
<div class="col-md-4 d-flex align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="gatewayModalInStock">
<label class="form-check-label" for="gatewayModalInStock">Kun paa lager</label>
<label class="form-check-label" for="gatewayModalInStock">Kun på lager</label>
</div>
</div>
<div class="col-md-4">
@ -328,7 +328,7 @@
</div>
</div>
<div id="gatewayResults">
<div class="text-center text-muted py-4">Soeg efter produkter for at importere.</div>
<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>
</div>
</div>
</div>
@ -352,13 +352,13 @@
<div class="col-md-6">
<label class="form-label">Type</label>
<select class="form-select" id="productType">
<option value="">- Vaelg type -</option>
<option value="">- Vælg type -</option>
<option value="hardware">Hardware</option>
<option value="service">Service</option>
<option value="subscription">Abonnement</option>
<option value="bundle">Bundle</option>
</select>
<div class="form-text">Vaelg produkttype for korrekt kategorisering.</div>
<div class="form-text">Vælg produkttype for korrekt kategorisering.</div>
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
@ -397,9 +397,9 @@
<label class="form-label">Faktureringsinterval</label>
<select class="form-select" id="productBillingPeriod">
<option value="">-</option>
<option value="monthly">Maaned</option>
<option value="monthly">Måned</option>
<option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option>
<option value="yearly">År</option>
<option value="one_time">Engang</option>
</select>
</div>
@ -424,7 +424,7 @@
</div>
<div class="col-12">
<div class="alert alert-light small">
Avancerede felter kan tilfoejes senere via API.
Avancerede felter kan tilføjes senere via API.
</div>
</div>
</div>
@ -461,7 +461,7 @@ async function loadProducts() {
document.getElementById('productsBody').innerHTML = `
<tr><td colspan="6" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
@ -609,14 +609,14 @@ async function searchAndCreateByGatewayCode() {
const code = queryInput ? queryInput.value.trim() : '';
if (!code) {
alert('Skriv EAN eller strengkode i soegefeltet foerst');
alert('Skriv EAN eller stregkode i søgefeltet først');
return;
}
const oldHtml = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Soeger...';
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Søger...';
}
try {
@ -652,7 +652,7 @@ async function searchAndCreateByGatewayCode() {
} finally {
if (button) {
button.disabled = false;
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)';
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)';
}
}
}
@ -751,9 +751,9 @@ function formatCurrency(amount) {
function formatInterval(interval) {
const map = {
'monthly': 'Maaned',
'monthly': 'Måned',
'quarterly': 'Kvartal',
'yearly': 'Aar',
'yearly': 'År',
'one_time': 'Engang'
};
return map[interval] || interval || '-';
@ -782,7 +782,7 @@ async function createProduct() {
};
if (!payload.name) {
alert('Navn er paakraevet');
alert('Navn er påkrævet');
return;
}
@ -810,7 +810,7 @@ function openGatewaySearchModal(autoSearch = false) {
document.getElementById('gatewayModalMinPrice').value = '';
document.getElementById('gatewayModalMaxPrice').value = '';
document.getElementById('gatewayModalInStock').checked = false;
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Soeg efter produkter for at importere.</div>';
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>';
new bootstrap.Modal(document.getElementById('gatewaySearchModal')).show();
if (autoSearch) {
searchGatewayProducts(true);
@ -836,7 +836,7 @@ async function searchGatewayProducts(fromModal = false) {
const manufacturer = manufacturerInput ? manufacturerInput.value.trim() : '';
const inStock = inStockInput ? inStockInput.checked : false;
if (!q && !supplier) {
alert('Angiv soegeord eller supplier code');
alert('Angiv søgeord eller leverandørkode');
return;
}
@ -851,14 +851,14 @@ async function searchGatewayProducts(fromModal = false) {
const resultsContainer = document.getElementById('gatewayResults');
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>Soege...</div>';
resultsContainer.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`);
if (!res.ok) {
const error = await res.text();
throw new Error(error || 'Gateway soegning fejlede');
throw new Error(error || 'Gateway-søgning fejlede');
}
const data = await res.json();
const products = Array.isArray(data.products) ? data.products : [];
@ -866,7 +866,7 @@ async function searchGatewayProducts(fromModal = false) {
} catch (e) {
console.error(e);
if (resultsContainer) {
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved soegning'}</div>`;
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved søgning'}</div>`;
}
}
}
@ -1041,7 +1041,7 @@ function renderGatewayResults() {
</select>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" ${page <= 1 ? 'disabled' : ''} onclick="goGatewayPage(-1)">Forrige</button>
<button class="btn btn-outline-secondary" ${page >= totalPages ? 'disabled' : ''} onclick="goGatewayPage(1)">Naeste</button>
<button class="btn btn-outline-secondary" ${page >= totalPages ? 'disabled' : ''} onclick="goGatewayPage(1)">Næste</button>
</div>
</div>
</div>
@ -1132,7 +1132,7 @@ function importGatewayProductFromAttr(button) {
const product = JSON.parse(decodeURIComponent(payload));
importGatewayProduct(product);
} catch (e) {
alert('Kunne ikke laese produktdata');
alert('Kunne ikke læse produktdata');
}
}

View File

@ -363,9 +363,10 @@ async def register_manual_session(data: dict):
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
async def list_case_anydesk_ids(sag_id: int):
"""List saved AnyDesk IDs for a case (multi-ID support)."""
"""List all useful AnyDesk targets for a case, deduplicated by ID."""
try:
_ensure_case_exists(sag_id)
case_row = _ensure_case_exists(sag_id)
customer_id = case_row.get("customer_id")
rows = execute_query(
"""
@ -400,8 +401,126 @@ async def list_case_anydesk_ids(sag_id: int):
if serial:
fragments.append(f"SN: {serial}")
row["hardware_label"] = " - ".join(fragments) if fragments else None
row["source"] = "case"
return {"ids": rows}
history_rows = execute_query(
"""
SELECT
COALESCE(
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
NULLIF(TRIM(s.device_info->>'to_id'), '')
) AS anydesk_id,
s.hardware_asset_id,
MAX(s.started_at) AS last_used_at,
MAX(
NULLIF(
TRIM(CONCAT_WS(' ', c.first_name, c.last_name)),
''
)
) AS contact_name,
MAX(
NULLIF(
TRIM(CONCAT_WS(' ', h.brand, h.model)),
''
)
) AS hardware_label
FROM anydesk_sessions s
LEFT JOIN contacts c ON c.id = s.contact_id
LEFT JOIN hardware_assets h ON h.id = s.hardware_asset_id
WHERE (
s.sag_id = %s
OR (%s IS NOT NULL AND s.customer_id = %s)
OR s.contact_id IN (
SELECT sk.contact_id
FROM sag_kontakter sk
WHERE sk.sag_id = %s
AND sk.deleted_at IS NULL
)
)
AND COALESCE(
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
NULLIF(TRIM(s.device_info->>'to_id'), '')
) IS NOT NULL
GROUP BY
COALESCE(
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
NULLIF(TRIM(s.device_info->>'to_id'), '')
),
s.hardware_asset_id
ORDER BY MAX(s.started_at) DESC NULLS LAST
""",
(sag_id, customer_id, customer_id, sag_id),
) or []
hardware_rows = execute_query(
"""
SELECT DISTINCT
h.id AS hardware_asset_id,
h.anydesk_id,
NULLIF(
TRIM(CONCAT_WS(
' - ',
NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), ''),
CASE
WHEN NULLIF(TRIM(h.serial_number), '') IS NOT NULL
THEN 'SN: ' || TRIM(h.serial_number)
ELSE NULL
END
)),
''
) AS hardware_label
FROM hardware_assets h
LEFT JOIN sag_hardware sh
ON sh.hardware_id = h.id
AND sh.sag_id = %s
AND sh.deleted_at IS NULL
WHERE h.deleted_at IS NULL
AND NULLIF(TRIM(h.anydesk_id), '') IS NOT NULL
AND (
sh.id IS NOT NULL
OR (%s IS NOT NULL AND h.current_owner_customer_id = %s)
)
ORDER BY hardware_label NULLS LAST
""",
(sag_id, customer_id, customer_id),
) or []
merged = {}
def add_target(target: dict, source: str) -> None:
anydesk_id = _normalize_anydesk_id(target.get("anydesk_id"))
if not anydesk_id:
return
target = dict(target)
target["anydesk_id"] = anydesk_id
target["source"] = source
existing = merged.get(anydesk_id)
if not existing:
merged[anydesk_id] = target
return
# A manually saved case target remains authoritative, but enrich it
# with contact/hardware/history information from other sources.
for key in ("hardware_asset_id", "hardware_label", "contact_name", "last_used_at"):
if not existing.get(key) and target.get(key):
existing[key] = target[key]
for row in rows:
add_target(row, "case")
for row in hardware_rows:
add_target(row, "hardware")
for row in history_rows:
add_target(row, "contact" if row.get("contact_name") else "history")
targets = list(merged.values())
targets.sort(
key=lambda item: (
0 if item.get("is_primary") else 1,
0 if item.get("source") == "case" else 1,
str(item.get("hardware_label") or item.get("contact_name") or item.get("anydesk_id")),
)
)
return {"ids": targets}
except HTTPException:
raise
except Exception as e:
@ -1411,4 +1530,3 @@ async def anydesk_hardware_list():
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -159,12 +159,20 @@ class EmailProcessorService:
email_id,
)
if require_manual_approval and not has_helpdesk_hint:
# Bankruptcy notices are a safety-critical system workflow and run
# automatically, but only act when an explicit CVR matches exactly.
is_bankruptcy = classification == 'bankruptcy'
if require_manual_approval and not has_helpdesk_hint and not is_bankruptcy:
await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
stats['awaiting_user_action'] = True
return stats
if (not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD) and not has_helpdesk_hint:
if (
(not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD)
and not has_helpdesk_hint
and not is_bankruptcy
):
await self._set_awaiting_user_action(email_id, reason='low_confidence')
stats['awaiting_user_action'] = True
return stats

View File

@ -1598,7 +1598,7 @@ class EmailService:
# Prefer Graph send when Graph integration is enabled/configured.
if self._graph_send_available():
graph_ok, graph_message = await self._send_via_graph(
graph_ok, graph_message, _graph_metadata = await self._send_via_graph(
to_addresses=to_addresses,
subject=subject,
body_text=body_text,

View File

@ -182,9 +182,10 @@ class EmailWorkflowService:
# Safe parameterized query for variable list length
format_strings = ','.join(['%s'] * len(unique_cvrs))
query = f"""
SELECT id, name, cvr_number
FROM customers
WHERE cvr_number IN ({format_strings})
SELECT id, name, cvr_number
FROM customers
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ({format_strings})
AND is_active = true
"""
matching_customers = execute_query(query, tuple(unique_cvrs))
@ -200,28 +201,112 @@ class EmailWorkflowService:
)
return {'status': 'completed', 'action': 'marked_processed_no_match'}
logger.warning(f"⚠️ FOUND BANKRUPTCY MATCHES: {[c['name'] for c in matching_customers]}")
# Link to the first customer found (limitation of 1:1 schema)
logger.warning("⚠️ FOUND BANKRUPTCY MATCHES: %s", [c['name'] for c in matching_customers])
email_id = int(email_data["id"])
subject = str(email_data.get("subject") or "Konkursmeddelelse").strip()
body_text = self._extract_primary_email_body(email_data)
created_cases = []
alert_ids = []
for customer in matching_customers:
customer_id = int(customer["id"])
existing_case = execute_query(
"""
SELECT s.id, s.titel
FROM sag_sager s
JOIN sag_emails se ON se.sag_id = s.id
WHERE se.email_id = %s
AND s.customer_id = %s
AND s.deleted_at IS NULL
ORDER BY s.id ASC LIMIT 1
""",
(email_id, customer_id),
)
if existing_case:
case = existing_case[0]
else:
case_rows = execute_query(
"""
INSERT INTO sag_sager (
titel, beskrivelse, template_key, status, customer_id, created_by_user_id
)
VALUES (%s, %s, 'ticket', 'åben', %s, 1)
RETURNING id, titel, customer_id
""",
(
f"KONKURSVARSEL {customer['name']}: {subject}"[:255],
(
"Automatisk oprettet fra eksakt CVR-match i konkursmail.\n"
f"Kunde: {customer['name']}\n"
f"CVR: {customer['cvr_number']}\n"
f"Email-ID: {email_id}\n\n{body_text}"
),
customer_id,
),
)
if not case_rows:
raise ValueError(f"Kunne ikke oprette konkurssag for kunde {customer_id}")
case = case_rows[0]
self._add_helpdesk_comment(int(case["id"]), email_data)
self._link_email_to_sag(int(case["id"]), email_id)
created_cases.append(int(case["id"]))
alert_marker = f"Konkursmail Email-ID: {email_id}"
existing_alert = execute_query(
"""
SELECT id FROM alert_notes
WHERE entity_type = 'customer'
AND entity_id = %s
AND active = true
AND message LIKE %s
ORDER BY id ASC LIMIT 1
""",
(customer_id, f"%{alert_marker}%"),
)
if existing_alert:
alert_id = int(existing_alert[0]["id"])
else:
alert_rows = execute_query(
"""
INSERT INTO alert_notes (
entity_type, entity_id, title, message, severity,
requires_acknowledgement, active, created_by_user_id
)
VALUES ('customer', %s, %s, %s, 'critical', true, true, 1)
RETURNING id
""",
(
customer_id,
"KONKURSVARSEL kræver handling",
(
f"{alert_marker}\n"
f"Der er modtaget en konkursmeddelelse med eksakt CVR-match.\n"
f"CVR: {customer['cvr_number']}\n"
f"Sag: #{case['id']} {case['titel']}"
),
),
)
alert_id = int(alert_rows[0]["id"])
alert_ids.append(alert_id)
first_match = matching_customers[0]
execute_update(
"""UPDATE email_messages
SET customer_id = %s, status = 'processed', folder = 'Processed',
processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s""",
(first_match['id'], email_data['id'])
"""
UPDATE email_messages
SET customer_id = %s, linked_case_id = %s,
status = 'processed', folder = 'Processed',
processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s
""",
(first_match["id"], created_cases[0], email_id),
)
logger.info(f"🔗 Linked bankruptcy email {email_data['id']} to customer {first_match['name']} ({first_match['id']}) and marked as processed")
if len(matching_customers) > 1:
logger.warning(f"❗ Email contained multiple customer matches! Only linked to first one.")
return {
'status': 'completed',
'action': 'linked_customer',
'customer_name': first_match['name']
"status": "completed",
"action": "created_bankruptcy_cases_and_alerts",
"customer_ids": [int(c["id"]) for c in matching_customers],
"sag_ids": created_cases,
"alert_note_ids": alert_ids,
}
def _extract_sender_domain(self, email_data: Dict) -> Optional[str]:

View File

@ -25,8 +25,38 @@ class ReminderNotificationService:
def __init__(self):
self.email_service = EmailService()
self.mattermost_service = MattermostNotification()
# Reminder delivery has its own enable flag; backup notifications keep
# using the general MATTERMOST_ENABLED setting.
self.mattermost_service.enabled = settings.REMINDERS_MATTERMOST_ENABLED
self.dry_run = settings.REMINDERS_DRY_RUN
self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR
def _refresh_mattermost_settings(self) -> None:
"""Load runtime Mattermost configuration from the admin settings table."""
rows = execute_query(
"""
SELECT key, value
FROM settings
WHERE key IN ('mattermost_reminders_enabled', 'mattermost_webhook_url', 'mattermost_channel')
"""
) or []
values = {row["key"]: str(row.get("value") or "").strip() for row in rows}
enabled_value = values.get("mattermost_reminders_enabled")
enabled = (
enabled_value.lower() in {"1", "true", "yes", "on"}
if enabled_value is not None
else settings.REMINDERS_MATTERMOST_ENABLED
)
self.mattermost_service.enabled = bool(enabled and settings.REMINDERS_MATTERMOST_ENABLED)
self.mattermost_service.webhook_url = (
values.get("mattermost_webhook_url") or settings.MATTERMOST_WEBHOOK_URL
)
self.mattermost_service.channel = values.get("mattermost_channel") or settings.MATTERMOST_CHANNEL
@staticmethod
def _case_url(case_id: int) -> str:
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
return f"{base_url}/sag/{case_id}/v3"
async def send_reminder(
self,
@ -68,6 +98,7 @@ class ReminderNotificationService:
'rate_limited_users': [],
'logged_id': None
}
self._refresh_mattermost_settings()
if self.dry_run:
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
@ -102,27 +133,46 @@ class ReminderNotificationService:
)
# Get user email
user_query = "SELECT email FROM users WHERE user_id = %s"
user_query = "SELECT email, username FROM users WHERE user_id = %s"
user = execute_query(user_query, (user_id,))
user_email = user[0]['email'] if user else None
user_email = user_prefs.get('email_override') or (user[0]['email'] if user else None)
mattermost_username = (
user_prefs.get('mattermost_username')
or (user[0].get('username') if user else None)
)
# Send via channels
for channel in channels:
try:
if channel == 'mattermost' and settings.REMINDERS_MATTERMOST_ENABLED:
await self._send_mattermost(
if channel == 'mattermost':
if not settings.REMINDERS_MATTERMOST_ENABLED:
result['errors'].append('Mattermost reminders are disabled')
continue
sent = await self._send_mattermost(
reminder_title, reminder_message, sag_id, case_title,
priority, additional_info
priority, additional_info, mattermost_username
)
result['channels_used'].append('mattermost')
if sent:
result['channels_used'].append('mattermost')
else:
result['errors'].append('Mattermost delivery failed')
elif channel == 'email' and settings.REMINDERS_EMAIL_ENABLED and user_email:
await self._send_email(
elif channel == 'email':
if not settings.REMINDERS_EMAIL_ENABLED:
result['errors'].append('Email reminders are disabled')
continue
if not user_email:
result['errors'].append(f'User {user_id} has no email address')
continue
sent = await self._send_email(
user_email, reminder_title, reminder_message,
sag_id, case_title, customer_name, priority,
case_status, deadline, assigned_user, additional_info
)
result['channels_used'].append('email')
if sent:
result['channels_used'].append('email')
else:
result['errors'].append(f'Email delivery failed for {user_email}')
elif channel == 'frontend':
# Frontend notifications are handled by polling, no action needed here
@ -154,12 +204,17 @@ class ReminderNotificationService:
for email_addr in recipient_emails:
try:
if settings.REMINDERS_EMAIL_ENABLED:
await self._send_email(
sent = await self._send_email(
email_addr, reminder_title, reminder_message,
sag_id, case_title, customer_name, priority,
case_status, deadline, assigned_user, additional_info
)
result['channels_used'].append('email')
if sent:
result['channels_used'].append('email')
else:
result['errors'].append(f'Email delivery failed for {email_addr}')
else:
result['errors'].append('Email reminders are disabled')
except Exception as e:
error = f"Failed to send email to {email_addr}: {str(e)}"
@ -191,7 +246,8 @@ class ReminderNotificationService:
async def _get_user_preferences(self, user_id: int) -> Dict:
"""Get user notification preferences"""
query = """
SELECT notify_mattermost, notify_email, notify_frontend
SELECT notify_mattermost, notify_email, notify_frontend,
email_override, mattermost_username
FROM user_notification_preferences
WHERE user_id = %s
"""
@ -200,15 +256,19 @@ class ReminderNotificationService:
if result:
return {
'mattermost': result[0].get('notify_mattermost', True),
'email': result[0].get('notify_email', False),
'frontend': result[0].get('notify_frontend', True)
'email': result[0].get('notify_email', True),
'frontend': result[0].get('notify_frontend', True),
'email_override': result[0].get('email_override')
, 'mattermost_username': result[0].get('mattermost_username')
}
# Default preferences
return {
'mattermost': True,
'email': False,
'frontend': True
'email': True,
'frontend': True,
'email_override': None
, 'mattermost_username': None
}
def _determine_channels(
@ -221,6 +281,11 @@ class ReminderNotificationService:
) -> List[str]:
"""Determine which channels to use (merge user prefs with reminder overrides)"""
channels = []
if not override:
notify_mattermost = user_prefs.get('mattermost', True)
notify_email = user_prefs.get('email', True)
notify_frontend = user_prefs.get('frontend', True)
# Mattermost
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
@ -228,7 +293,7 @@ class ReminderNotificationService:
channels.append('mattermost')
# Email
em = notify_email if notify_email is not None else user_prefs.get('email', False)
em = notify_email if notify_email is not None else user_prefs.get('email', True)
if em:
channels.append('email')
@ -246,7 +311,8 @@ class ReminderNotificationService:
case_id: int,
case_title: str,
priority: str,
additional_info: Optional[str]
additional_info: Optional[str],
mattermost_username: Optional[str] = None,
) -> bool:
"""Send reminder via Mattermost"""
if self.dry_run:
@ -260,12 +326,13 @@ class ReminderNotificationService:
'high': '#ffc107',
'urgent': '#dc3545'
}
case_url = self._case_url(case_id)
payload = {
'text': f'🔔 **{title}**',
'text': f'🔔 **{title}**\n[Åbn sag #{case_id}]({case_url})',
'attachments': [{
'title': case_title,
'title_link': f"http://localhost:8001/sag/{case_id}/v3",
'title_link': case_url,
'text': message or additional_info or 'Se reminder i systemet',
'color': color_map.get(priority, color_map['normal']),
'fields': [
@ -279,17 +346,24 @@ class ReminderNotificationService:
'value': f'#{case_id}',
'short': True
}
],
'actions': [{
'name': 'Åbn sag',
'type': 'button',
'text': 'Se mere',
'url': f"http://localhost:8001/sag/{case_id}/v3"
}]
]
}]
}
if mattermost_username:
payload["channel"] = f"@{str(mattermost_username).strip().lstrip('@')}"
success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification')
if not success and mattermost_username:
# Some Mattermost webhooks are locked to their configured
# channel and reject channel="@user". Fall back to an explicit
# mention in that channel so the recipient still gets notified.
username = str(mattermost_username).strip().lstrip("@")
payload.pop("channel", None)
payload["text"] = f"@{username} {payload['text']}"
success, msg = await self.mattermost_service._send_webhook(
payload,
'reminder_notification_mention',
)
if success:
logger.info(f"✅ Mattermost reminder sent: {title}")
else:
@ -337,7 +411,7 @@ class ReminderNotificationService:
'deadline': deadline,
'assigned_user': assigned_user or 'Ikke tildelt',
'additional_info': additional_info or '',
'action_url': f"http://localhost:8001/sag/{case_id}/v3",
'action_url': self._case_url(case_id),
'footer_date': datetime.now().strftime("%d. %B %Y")
}

View File

@ -2,19 +2,29 @@
Settings and User Management API Router
"""
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime
from app.core.database import execute_query
from app.core.config import settings
from app.core.auth_dependencies import require_superadmin
from app.core.auth_service import AuthService
import argparse
import asyncio
import httpx
import time
import logging
import json
import os
import re
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
router = APIRouter()
_sag_test_lock = threading.Lock()
_sag_test_report_dir = Path(__file__).resolve().parents[3] / "data" / "test-results"
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
"{full_name}\n"
@ -52,6 +62,29 @@ class SettingCreate(BaseModel):
is_public: Optional[bool] = False
class MattermostTestRequest(BaseModel):
message: Optional[str] = "Test fra BMC Hub"
class SagTestRunRequest(BaseModel):
customer_id: Optional[int] = None
MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
("mattermost_channel", "", "Optional Mattermost channel override", "string"),
)
MASKED_SECRET = "********"
def _mask_setting(row: Dict) -> Dict:
item = dict(row)
if item.get("key") == "mattermost_webhook_url":
item["value"] = MASKED_SECRET if str(item.get("value") or "").strip() else ""
return item
class User(BaseModel):
id: int
username: str
@ -75,6 +108,80 @@ class UserUpdate(BaseModel):
is_active: Optional[bool] = None
def _load_sag_test_reports(limit: int = 20) -> List[Dict]:
if not _sag_test_report_dir.exists():
return []
reports = []
for path in sorted(_sag_test_report_dir.glob("sag-e2e-*.json"), reverse=True)[:limit]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["report_file"] = path.name
reports.append(payload)
except (OSError, json.JSONDecodeError):
logger.warning("Kunne ikke læse Sag-testrapport: %s", path)
return reports
def _execute_sag_test(current_user: Dict, customer_id: Optional[int]) -> Dict:
from app.modules.sag.scripts.sag_module_e2e import Result, SagE2E
_sag_test_report_dir.mkdir(parents=True, exist_ok=True)
report_path = _sag_test_report_dir / f"sag-e2e-{datetime.now():%Y%m%d-%H%M%S}.json"
token = AuthService.create_access_token(
user_id=int(current_user["id"]),
username=str(current_user["username"]),
is_superadmin=bool(current_user.get("is_superadmin")),
is_shadow_admin=bool(current_user.get("is_shadow_admin", False)),
)
args = argparse.Namespace(
base_url=os.getenv("SAG_TEST_BASE_URL", "http://127.0.0.1:8000"),
token=token,
username=None,
password=None,
otp=None,
customer_id=customer_id,
timeout=20.0,
keep_data=False,
json_report=str(report_path),
)
suite = SagE2E(args)
try:
suite.run()
except Exception as exc:
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
finally:
suite.cleanup_all()
suite.write_report()
payload = json.loads(report_path.read_text(encoding="utf-8"))
payload["report_file"] = report_path.name
return payload
@router.get("/settings/tests/sag", tags=["Settings Tests"])
async def get_sag_test_reports(
current_user: dict = Depends(require_superadmin),
):
reports = _load_sag_test_reports()
return {
"running": _sag_test_lock.locked(),
"latest": reports[0] if reports else None,
"history": reports,
}
@router.post("/settings/tests/sag/run", tags=["Settings Tests"])
async def run_sag_test(
payload: SagTestRunRequest,
current_user: dict = Depends(require_superadmin),
):
if not _sag_test_lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="Sag-testen kører allerede")
try:
return await asyncio.to_thread(_execute_sag_test, current_user, payload.customer_id)
finally:
_sag_test_lock.release()
# Settings Endpoints
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None):
@ -102,6 +209,16 @@ async def get_settings(category: Optional[str] = None):
True,
),
)
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, 'notifications', %s, %s, false),
(%s, %s, 'notifications', %s, %s, false),
(%s, %s, 'notifications', %s, %s, false)
ON CONFLICT (key) DO NOTHING
""",
tuple(value for item in MATTERMOST_SETTING_DEFAULTS for value in item),
)
query = "SELECT * FROM settings"
params = []
@ -112,7 +229,7 @@ async def get_settings(category: Optional[str] = None):
query += " ORDER BY category, key"
result = execute_query(query, tuple(params) if params else None)
return result or []
return [_mask_setting(row) for row in (result or [])]
@router.post("/settings", response_model=Setting, tags=["Settings"])
@ -147,7 +264,7 @@ async def create_setting(payload: SettingCreate):
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create setting")
return result[0]
return _mask_setting(result[0])
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
@ -168,7 +285,7 @@ async def get_setting(key: str):
seed_query,
(
"case_types",
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
"system",
"Sags-typer",
"json",
@ -231,12 +348,25 @@ async def get_setting(key: str):
if not result:
raise HTTPException(status_code=404, detail="Setting not found")
return result[0]
return _mask_setting(result[0])
@router.put("/settings/{key}", response_model=Setting, tags=["Settings"])
async def update_setting(key: str, setting: SettingUpdate):
"""Update a setting value"""
if key == "mattermost_webhook_url" and setting.value == MASKED_SECRET:
current = execute_query("SELECT * FROM settings WHERE key = %s", (key,))
if not current:
raise HTTPException(status_code=404, detail="Setting not found")
return _mask_setting(current[0])
if key == "mattermost_channel":
channel = setting.value.strip()
if channel and not re.fullmatch(r"[a-z0-9_-]+", channel):
raise HTTPException(
status_code=400,
detail="Brug Mattermost-kanalens tekniske navn uden mellemrum, fx mollypim-logs",
)
query = """
UPDATE settings
SET value = %s, updated_at = CURRENT_TIMESTAMP
@ -388,7 +518,62 @@ async def update_setting(key: str, setting: SettingUpdate):
raise HTTPException(status_code=404, detail="Setting not found")
logger.info(f"✅ Updated setting: {key}")
return result[0]
return _mask_setting(result[0])
@router.post("/settings/mattermost/test", tags=["Settings"])
async def test_mattermost_setting(payload: MattermostTestRequest):
rows = execute_query(
"SELECT key, value FROM settings WHERE key IN ('mattermost_webhook_url', 'mattermost_channel')"
) or []
values = {row["key"]: str(row.get("value") or "").strip() for row in rows}
webhook_url = values.get("mattermost_webhook_url", "")
if not webhook_url:
raise HTTPException(status_code=400, detail="Mattermost webhook URL mangler")
if not webhook_url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="Mattermost webhook URL skal starte med http:// eller https://")
message = str(payload.message or "Test fra BMC Hub").strip()[:500]
mattermost_payload = {"text": f"✅ **{message}**", "username": "BMC Hub"}
if values.get("mattermost_channel"):
mattermost_payload["channel"] = values["mattermost_channel"]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(webhook_url, json=mattermost_payload)
response_text = response.text
if (
response.status_code in (400, 404, 415)
and (
"media type application/json" in response_text.lower()
or "incoming_webhook.general.app_error" in response_text.lower()
)
):
response = await client.post(
webhook_url,
data={"payload": json.dumps(mattermost_payload, ensure_ascii=False)},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code not in (200, 201, 204):
response_body = response.text[:500]
if "incoming_webhook.general.app_error" in response_body:
raise HTTPException(
status_code=400,
detail=(
"Webhooken blev afvist af Mattermost. Opret en ny integration under "
"Mattermost → Integrations → Incoming Webhooks, og kopiér hele URL'en "
"som Mattermost genererer. Et outgoing webhook-ID eller et deaktiveret "
"incoming webhook kan ikke bruges."
),
)
raise HTTPException(
status_code=502,
detail=f"Mattermost returnerede HTTP {response.status_code}: {response.text[:200]}",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Mattermost: {exc}") from exc
return {"success": True, "message": "Testbesked sendt til Mattermost"}
@router.get("/settings/categories/list", tags=["Settings"])

View File

@ -228,7 +228,7 @@
lastError = errMsg;
} catch (err) {
attempts.push(`${url} -> ERR`);
lastError = err.message || 'Netvaerksfejl';
lastError = err.message || 'Netværksfejl';
}
}
@ -335,7 +335,7 @@
lastError = errMsg;
} catch (err) {
attempts.push(`${url} -> ERR`);
lastError = err.message || 'Netvaerksfejl';
lastError = err.message || 'Netværksfejl';
}
}

View File

@ -61,6 +61,26 @@
justify-content: center;
font-weight: bold;
}
.test-result-row {
display: grid;
grid-template-columns: 82px minmax(180px, 1fr) auto;
gap: 0.75rem;
align-items: start;
padding: 0.7rem 0;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,.08));
}
.test-result-row:last-child {
border-bottom: 0;
}
.test-result-detail {
grid-column: 2 / -1;
color: var(--text-secondary);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
</style>
{% endblock %}
@ -122,6 +142,9 @@
<a class="nav-link" href="#mission" data-tab="mission">
<i class="bi bi-broadcast-pin me-2"></i>Mission
</a>
<a class="nav-link" href="#tests" data-tab="tests">
<i class="bi bi-clipboard2-pulse me-2"></i>Tests
</a>
<a class="nav-link" href="#system" data-tab="system">
<i class="bi bi-gear me-2"></i>System
</a>
@ -469,7 +492,7 @@
<!-- Notifications -->
<div class="tab-pane fade" id="notifications">
<div class="card p-4">
<div class="card p-4 mb-4">
<h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5>
<div id="notificationSettings">
<div class="text-center py-5">
@ -477,6 +500,41 @@
</div>
</div>
</div>
<div class="card p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<h5 class="mb-1 fw-bold">Mattermost</h5>
<p class="text-muted mb-0">Send sagsreminders til en Mattermost incoming webhook.</p>
</div>
<span class="badge bg-secondary" id="mattermostSettingsState">Ikke konfigureret</span>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="mattermostRemindersEnabled">
<label class="form-check-label" for="mattermostRemindersEnabled">Aktivér Mattermost-reminders</label>
</div>
<div class="row g-3">
<div class="col-lg-8">
<label class="form-label" for="mattermostWebhookUrl">Incoming webhook URL</label>
<input type="password" class="form-control" id="mattermostWebhookUrl"
placeholder="https://mattermost.example/hooks/...">
<div class="form-text">En gemt webhook vises aldrig igen. Lad feltet være tomt for at beholde den nuværende.</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="mattermostChannel">Kanal (valgfri)</label>
<input type="text" class="form-control" id="mattermostChannel" placeholder="fx mollypim-logs">
<div class="form-text">Brug kanalens tekniske navn/slug uden mellemrum. Tomt felt bruger webhookens standardkanal.</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-4">
<button class="btn btn-primary" type="button" onclick="saveMattermostSettings()">
<i class="bi bi-save me-2"></i>Gem Mattermost
</button>
<button class="btn btn-outline-primary" type="button" onclick="testMattermostSettings()">
<i class="bi bi-send me-2"></i>Send test
</button>
</div>
<div class="small mt-3" id="mattermostSettingsFeedback"></div>
</div>
</div>
<!-- Email Templates -->
@ -577,7 +635,7 @@
<select class="form-select" id="taskTemplateSourceFilter" onchange="loadTaskTemplates()">
<option value="all">Alle</option>
<option value="company">Firma</option>
<option value="global">Faelles</option>
<option value="global">Fælles</option>
<option value="internal">Intern</option>
</select>
</div>
@ -589,7 +647,7 @@
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="brugerandring">Brugerændring</option>
<option value="andet">Andet</option>
</select>
</div>
@ -634,11 +692,11 @@
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="fw-bold mb-0" id="taskTemplateItemsTitle">Template-opgaver</h6>
<small class="text-muted" id="taskTemplateItemsSubtitle">Vaelg en template for at redigere items</small>
<small class="text-muted" id="taskTemplateItemsSubtitle">Vælg en template for at redigere elementer</small>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="taskTemplateAddItemBtn" onclick="openTaskTemplateItemModal()" disabled>
<i class="bi bi-plus-lg me-1"></i>Tilfoej
<i class="bi bi-plus-lg me-1"></i>Tilføj
</button>
<button class="btn btn-sm btn-outline-secondary" id="taskTemplateBulkBuilderBtn" onclick="openTaskTemplateBulkBuilderModal()" disabled>
<i class="bi bi-magic me-1"></i>Builder
@ -1020,7 +1078,7 @@
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
<small class="text-muted">Overvåger, om alle arkiverede tickets er synkroniseret (kildeantal mod lokal database)</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
@ -1066,7 +1124,7 @@
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
<small class="text-muted" id="archivedStatusHint">Polling er aktiv, når Sync-fanen er åben.</small>
</div>
</div>
</div>
@ -1507,6 +1565,84 @@ async def scan_document(file_path: str):
</div>
</div>
<!-- Automated tests -->
<div class="tab-pane fade" id="tests">
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<h5 class="fw-bold mb-0">Sag-modul komplet funktionstest</h5>
<span class="badge text-bg-secondary" id="sagTestState">Ikke kørt</span>
</div>
<p class="text-muted mb-0">
Tester sager, undersager, relationer, todos, kommentarer, tags,
buzzwords, søgning, ordrelinjer og filer. Testdata ryddes automatisk op.
</p>
</div>
<button class="btn btn-primary" type="button" id="runSagTestBtn" onclick="runSagModuleTest()">
<i class="bi bi-play-fill me-2"></i>Kør Sag-test
</button>
</div>
<div class="alert alert-warning py-2 mt-3 mb-0 small">
Kun superadmins kan starte testen. Mail, AnyDesk, printer og eksterne
økonomisystemer markeres som SKIP, fordi de kræver eksterne tjenester eller hardware.
</div>
</div>
<div class="row g-3 mb-4" id="sagTestSummary">
<div class="col-md-4">
<div class="card p-3 border-success">
<div class="small text-muted text-uppercase fw-semibold">Bestået</div>
<div class="fs-2 fw-bold text-success" id="sagTestPassCount"></div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 border-danger">
<div class="small text-muted text-uppercase fw-semibold">Fejl</div>
<div class="fs-2 fw-bold text-danger" id="sagTestFailCount"></div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 border-secondary">
<div class="small text-muted text-uppercase fw-semibold">Sprunget over</div>
<div class="fs-2 fw-bold text-secondary" id="sagTestSkipCount"></div>
</div>
</div>
</div>
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="fw-bold mb-1">Seneste resultat</h6>
<div class="small text-muted" id="sagTestMeta">Ingen testrapport endnu.</div>
</div>
</div>
<div id="sagTestResults">
<div class="text-muted">Kør testen for at se resultater.</div>
</div>
</div>
<div class="card p-4">
<h6 class="fw-bold mb-3">Tidligere kørsler</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Tidspunkt</th>
<th>Bestået</th>
<th>Fejl</th>
<th>Skip</th>
</tr>
</thead>
<tbody id="sagTestHistory">
<tr><td colspan="4" class="text-muted">Ingen historik endnu.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- System Settings -->
<div class="tab-pane fade" id="system">
<div class="card p-4 mb-4">
@ -1974,10 +2110,11 @@ let pipelineStagesCache = [];
let nextcloudInstancesCache = [];
let customersCache = [];
let timeMultiplierPresetsCache = [];
let sagTestReportsCache = [];
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
@ -2463,9 +2600,9 @@ function renderDriftConnectors() {
<label class="form-label fw-semibold">Ignorer varetekster</label>
<div class="input-group">
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilføj</button>
</div>
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
<div class="form-text">Matcher på varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
</div>
</div>
@ -2511,7 +2648,7 @@ async function loadSettings() {
try {
const response = await fetch('/api/v1/settings', { credentials: 'include' });
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese indstillinger'));
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse indstillinger'));
}
const payload = await response.json();
allSettings = Array.isArray(payload) ? payload : [];
@ -2607,11 +2744,11 @@ function addTimeMultiplierPreset() {
const multiplier = Number(multiplierInput.value || 0);
if (!label) {
showNotification('Navn er paakraevet', 'error');
showNotification('Navn er påkrævet', 'error');
return;
}
if (!Number.isFinite(multiplier) || multiplier <= 0) {
showNotification('Multiplier skal vaere stoerre end 0', 'error');
showNotification('Multiplikatoren skal være større end 0', 'error');
return;
}
@ -2672,6 +2809,7 @@ function displaySettingsByCategory() {
// Notification settings
displaySettings('notificationSettings', categories.notifications);
renderMattermostSettings();
// Email templates
displaySettings('emailTemplatesInternal', [
@ -2689,6 +2827,100 @@ function displaySettingsByCategory() {
displaySettings('systemSettings', categories.system);
}
function renderMattermostSettings() {
const enabled = allSettings.find(s => s.key === 'mattermost_reminders_enabled');
const webhook = allSettings.find(s => s.key === 'mattermost_webhook_url');
const channel = allSettings.find(s => s.key === 'mattermost_channel');
const enabledEl = document.getElementById('mattermostRemindersEnabled');
const webhookEl = document.getElementById('mattermostWebhookUrl');
const channelEl = document.getElementById('mattermostChannel');
const stateEl = document.getElementById('mattermostSettingsState');
if (!enabledEl || !webhookEl || !channelEl || !stateEl) return;
enabledEl.checked = enabled?.value === 'true';
webhookEl.value = '';
channelEl.value = channel?.value || '';
const configured = webhook?.value === '********';
webhookEl.placeholder = configured
? 'Webhook er gemt - indtast kun for at ændre'
: 'https://mattermost.example/hooks/...';
stateEl.className = `badge ${configured && enabledEl.checked ? 'bg-success' : 'bg-secondary'}`;
stateEl.textContent = configured
? (enabledEl.checked ? 'Aktiv' : 'Webhook gemt')
: 'Ikke konfigureret';
}
async function saveMattermostSettings(showToast = true) {
const enabled = document.getElementById('mattermostRemindersEnabled').checked;
const webhook = document.getElementById('mattermostWebhookUrl').value.trim();
const channel = document.getElementById('mattermostChannel').value.trim();
const feedback = document.getElementById('mattermostSettingsFeedback');
if (webhook && !/^https?:\/\//i.test(webhook)) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = 'Webhook URL skal starte med http:// eller https://';
return false;
}
if (channel && !/^[a-z0-9_-]+$/.test(channel)) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = 'Brug kanalens tekniske navn uden mellemrum, fx mollypim-logs.';
return false;
}
feedback.className = 'small mt-3 text-muted';
feedback.textContent = 'Gemmer Mattermost-indstillinger...';
const updates = [
['mattermost_reminders_enabled', enabled ? 'true' : 'false'],
['mattermost_channel', channel],
];
if (webhook) updates.push(['mattermost_webhook_url', webhook]);
try {
for (const [key, value] of updates) {
const response = await fetch(`/api/v1/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value}),
});
if (!response.ok) throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
const saved = await response.json();
setOrAddSettingInCache(key, saved.value);
}
if (webhook) setOrAddSettingInCache('mattermost_webhook_url', '********');
renderMattermostSettings();
feedback.className = 'small mt-3 text-success';
feedback.textContent = 'Mattermost-indstillinger gemt.';
if (showToast) showNotification('Mattermost-indstillinger gemt', 'success');
return true;
} catch (error) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = error.message || 'Kunne ikke gemme Mattermost-indstillinger';
return false;
}
}
async function testMattermostSettings() {
const feedback = document.getElementById('mattermostSettingsFeedback');
if (!await saveMattermostSettings(false)) return;
feedback.className = 'small mt-3 text-muted';
feedback.textContent = 'Sender testbesked...';
try {
const response = await fetch('/api/v1/settings/mattermost/test', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'Mattermost virker fra BMC Hub'}),
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Testbeskeden fejlede'));
feedback.className = 'small mt-3 text-success';
feedback.textContent = 'Testbesked sendt til Mattermost.';
showNotification('Mattermost-test sendt', 'success');
} catch (error) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = error.message || 'Kunne ikke sende Mattermost-test';
}
}
async function loadAnydeskSettings() {
const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
try {
@ -3879,7 +4111,7 @@ async function loadAdminUsers() {
try {
const response = await fetch('/api/v1/admin/users');
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese brugere'));
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse brugere'));
}
usersCache = await response.json();
displayUsers(usersCache);
@ -3887,7 +4119,7 @@ async function loadAdminUsers() {
} catch (error) {
console.error('Error loading users:', error);
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlaese brugere')}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlæse brugere')}</td></tr>`;
}
}
@ -4762,6 +4994,121 @@ function formatDate(dateString) {
});
}
function sagTestEscape(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderSagTestReport(report) {
const state = document.getElementById('sagTestState');
const results = document.getElementById('sagTestResults');
if (!report) {
state.className = 'badge text-bg-secondary';
state.textContent = 'Ikke kørt';
document.getElementById('sagTestPassCount').textContent = '';
document.getElementById('sagTestFailCount').textContent = '';
document.getElementById('sagTestSkipCount').textContent = '';
document.getElementById('sagTestMeta').textContent = 'Ingen testrapport endnu.';
results.innerHTML = '<div class="text-muted">Kør testen for at se resultater.</div>';
return;
}
const summary = report.summary || {};
const failed = Number(summary.FAIL || 0);
state.className = `badge ${failed ? 'text-bg-danger' : 'text-bg-success'}`;
state.textContent = failed ? 'Fejl fundet' : 'Alle kernetests bestået';
document.getElementById('sagTestPassCount').textContent = summary.PASS ?? 0;
document.getElementById('sagTestFailCount').textContent = summary.FAIL ?? 0;
document.getElementById('sagTestSkipCount').textContent = summary.SKIP ?? 0;
document.getElementById('sagTestMeta').textContent =
`${formatDate(report.created_at)} · Kørsel ${report.run_id || 'ukendt'}`;
const badgeClass = { PASS: 'text-bg-success', FAIL: 'text-bg-danger', SKIP: 'text-bg-secondary' };
results.innerHTML = (report.results || []).map(item => `
<div class="test-result-row">
<span class="badge ${badgeClass[item.status] || 'text-bg-secondary'}">${sagTestEscape(item.status)}</span>
<div class="fw-semibold">${sagTestEscape(item.name)}</div>
<div class="small text-muted">${Number(item.duration_ms || 0)} ms</div>
${item.detail ? `<div class="test-result-detail">${sagTestEscape(item.detail)}</div>` : ''}
</div>
`).join('') || '<div class="text-muted">Rapporten indeholder ingen resultater.</div>';
}
function renderSagTestHistory(reports) {
sagTestReportsCache = Array.isArray(reports) ? reports : [];
const body = document.getElementById('sagTestHistory');
if (!sagTestReportsCache.length) {
body.innerHTML = '<tr><td colspan="4" class="text-muted">Ingen historik endnu.</td></tr>';
return;
}
body.innerHTML = sagTestReportsCache.map((report, index) => {
const summary = report.summary || {};
return `
<tr role="button" onclick="renderSagTestReport(sagTestReportsCache[${index}])">
<td>${sagTestEscape(formatDate(report.created_at))}</td>
<td><span class="text-success fw-semibold">${Number(summary.PASS || 0)}</span></td>
<td><span class="text-danger fw-semibold">${Number(summary.FAIL || 0)}</span></td>
<td><span class="text-secondary">${Number(summary.SKIP || 0)}</span></td>
</tr>
`;
}).join('');
}
async function loadSagModuleTests() {
const state = document.getElementById('sagTestState');
try {
const response = await fetch('/api/v1/settings/tests/sag', { credentials: 'include' });
if (!response.ok) throw new Error(await extractApiError(response, 'Kunne ikke hente tests'));
const data = await response.json();
renderSagTestReport(data.latest);
renderSagTestHistory(data.history);
if (data.running) {
state.className = 'badge text-bg-primary';
state.textContent = 'Kører…';
}
} catch (error) {
state.className = 'badge text-bg-danger';
state.textContent = 'Kan ikke hente';
document.getElementById('sagTestResults').innerHTML =
`<div class="alert alert-danger mb-0">${sagTestEscape(error.message)}</div>`;
}
}
async function runSagModuleTest() {
const button = document.getElementById('runSagTestBtn');
const state = document.getElementById('sagTestState');
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Kører test…';
state.className = 'badge text-bg-primary';
state.textContent = 'Kører…';
document.getElementById('sagTestResults').innerHTML =
'<div class="d-flex align-items-center gap-2 text-muted"><span class="spinner-border spinner-border-sm"></span>Tester Sag-modulet og rydder testdata op…</div>';
try {
const response = await fetch('/api/v1/settings/tests/sag/run', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!response.ok) throw new Error(await extractApiError(response, 'Sag-testen fejlede'));
const report = await response.json();
renderSagTestReport(report);
await loadSagModuleTests();
} catch (error) {
state.className = 'badge text-bg-danger';
state.textContent = 'Kørsel fejlede';
document.getElementById('sagTestResults').innerHTML =
`<div class="alert alert-danger mb-0">${sagTestEscape(error.message)}</div>`;
} finally {
button.disabled = false;
button.innerHTML = '<i class="bi bi-play-fill me-2"></i>Kør Sag-test';
}
}
// Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => {
@ -4773,6 +5120,7 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
}
e.preventDefault();
history.replaceState(null, '', `#${tab}`);
// Update nav
document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active'));
@ -4800,6 +5148,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadAIPrompts();
} else if (tab === 'modules') {
loadModules();
} else if (tab === 'tests') {
loadSagModuleTests();
}
});
});
@ -5924,7 +6274,7 @@ async function loadPipelineStages() {
try {
const response = await fetch('/api/v1/pipeline/stages');
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese pipeline stages'));
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse pipeline-trin'));
}
const payload = await response.json();
const stages = Array.isArray(payload) ? payload : [];
@ -6103,6 +6453,7 @@ const MENU_VISIBILITY_GROUPS = [
{
title: 'Data migration underpunkter',
items: [
{ key: 'menu-datamigration-migration-center', label: 'Migreringscenter' },
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' },
{ key: 'menu-datamigration-registrations', label: 'Registreringer' },
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
@ -6210,6 +6561,12 @@ document.addEventListener('DOMContentLoaded', () => {
loadPipelineStages();
loadMenuVisibilityPreferences();
const requestedTab = window.location.hash.replace('#', '');
const requestedLink = requestedTab
? document.querySelector(`.settings-nav .nav-link[data-tab="${CSS.escape(requestedTab)}"]`)
: null;
if (requestedLink) requestedLink.click();
const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
if (saveMenuVisibilityBtn) {
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
@ -6341,14 +6698,14 @@ document.addEventListener('DOMContentLoaded', () => {
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="brugerandring">Brugerændring</option>
<option value="andet" selected>Andet</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Firma (kun firma-type)</label>
<select id="taskTemplateCompany" class="form-select">
<option value="">Vaelg firma...</option>
<option value="">Vælg firma...</option>
</select>
</div>
</div>
@ -6371,7 +6728,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilfoej item</h5>
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilføj element</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@ -6492,21 +6849,21 @@ const TASK_TEMPLATE_BUILDER_PRESETS = {
onboarding: [
'task|0|Velkomstmail til bruger|Send velkomstmail med kontaktinfo og forventet plan',
'task|0|Bestil hardware|Bestil standard udstyr til ny medarbejder',
'task|1|Klargoer kontoer og licenser|Opsaet M365, VPN, sikkerhedsprofil og grupper',
'task|2|Planlaeg introduktionsmoede|Book intro med leder og support',
'subcase|0|Onboarding undersag: adgange|Opfoelgning paa adgangsrequests og godkendelser'
'task|1|Klargør konti og licenser|Opsæt M365, VPN, sikkerhedsprofil og grupper',
'task|2|Planlæg introduktionsmøde|Book introduktion med leder og support',
'subcase|0|Onboarding-undersag: adgange|Opfølgning på adgangsanmodninger og godkendelser'
],
offboarding: [
'task|0|Bekraeft fratraedelsesdato|Afstem sidste arbejdsdag med leder/HR',
'task|0|Bekræft fratrædelsesdato|Afstem sidste arbejdsdag med leder/HR',
'task|0|Luk adgange|Deaktiver M365, VPN og eksterne konti',
'task|1|Indsaml udstyr|Koordiner retur af laptop, mobil og noegler',
'task|1|Indsaml udstyr|Koordinér returnering af laptop, mobil og nøgler',
'subcase|0|Offboarding undersag: dokumentation|Saml audit-noter og afslutningsdokumentation'
],
hardware: [
'task|0|Afdaek behov|Afklar krav til model, tilbehoer og levering',
'task|0|Indhent pris|Kontroller pris og leveringstid hos leverandoer',
'task|0|Afdæk behov|Afklar krav til model, tilbehør og levering',
'task|0|Indhent pris|Kontrollér pris og leveringstid hos leverandør',
'task|1|Bestil hardware|Placer ordren og registrer ordrenummer',
'task|3|Levering og klargoering|Klargoer enhed og informer bruger'
'task|3|Levering og klargøring|Klargør enhed, og informér brugeren'
]
};
@ -6517,7 +6874,7 @@ function getTaskTemplateBuilderModal() {
function openTaskTemplateBulkBuilderModal() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
@ -6607,7 +6964,7 @@ function parseTaskTemplateBuilderLines(rawText, defaults = {}) {
async function importTaskTemplateBulkItems() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
@ -6683,7 +7040,7 @@ function taskTemplateCategoryLabel(category) {
offboarding: 'Offboarding',
simkort: 'Mobil / SIM-kort',
hardwarebestilling: 'Hardwarebestilling',
brugerandring: 'Brugeraendring',
brugerandring: 'Brugerændring',
andet: 'Andet'
};
return labels[category] || category || '-';
@ -6706,7 +7063,7 @@ async function loadTaskTemplateCustomers() {
if (!filterSelect || !modalSelect) return;
filterSelect.innerHTML = '<option value="">Alle kunder</option>';
modalSelect.innerHTML = '<option value="">Vaelg firma...</option>';
modalSelect.innerHTML = '<option value="">Vælg firma...</option>';
customers.forEach((customer) => {
const label = `${customer.name} (#${customer.id})`;
@ -6769,7 +7126,7 @@ async function loadTaskTemplates() {
`).join('');
} catch (error) {
console.error('Error loading task templates:', error);
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlaesning')}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlæsning')}</td></tr>`;
}
}
@ -6816,12 +7173,12 @@ async function saveTaskTemplateSettings() {
};
if (!payload.name) {
alert('Navn er paakraevet');
alert('Navn er påkrævet');
return;
}
if (templateType === 'company' && !payload.customer_id) {
alert('Vaelg firma for firma-template');
alert('Vælg firma til firmatemplate');
return;
}
@ -6858,7 +7215,7 @@ async function deactivateTaskTemplateSettings(templateId) {
if (selectedTaskTemplateId === templateId) {
selectedTaskTemplateId = null;
document.getElementById('taskTemplateItemsTitle').textContent = 'Template-opgaver';
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vaelg en template for at redigere items';
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vælg en template for at redigere elementer';
document.getElementById('taskTemplateItemsList').innerHTML = '<div class="list-group-item text-muted small">Ingen template valgt.</div>';
document.getElementById('taskTemplateAddItemBtn').disabled = true;
document.getElementById('taskTemplateBulkBuilderBtn').disabled = true;
@ -6938,13 +7295,13 @@ async function loadTaskTemplateItems(templateId) {
function openTaskTemplateItemModal() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
document.getElementById('taskTemplateItemForm').reset();
document.getElementById('taskTemplateItemId').value = '';
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilfoej item';
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilføj element';
document.getElementById('taskTemplateItemRequired').checked = true;
document.getElementById('taskTemplateItemActive').checked = true;
@ -6972,7 +7329,7 @@ function editTaskTemplateItem(item) {
async function saveTaskTemplateItem() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
@ -6991,7 +7348,7 @@ async function saveTaskTemplateItem() {
};
if (!payload.title) {
alert('Titel er paakraevet');
alert('Titel er påkrævet');
return;
}

View File

@ -22,7 +22,7 @@
--frame-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
--border-radius: 12px;
--bottom-bar-height: 50px;
--bottom-bar-expanded-height: 50vh;
--bottom-bar-expanded-height: 68vh;
--bottom-bar-zindex: 1030;
}
@ -53,7 +53,7 @@
}
body.bottom-bar-visible.bottom-bar-expanded {
padding-bottom: calc(var(--bottom-bar-height) + 52vh);
padding-bottom: calc(var(--bottom-bar-height) + var(--bottom-bar-expanded-height));
}
.global-bottom-bar {
@ -62,13 +62,14 @@
left: 0;
right: 0;
z-index: var(--bottom-bar-zindex);
background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(var(--text-primary-rgb), 0.1);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
background: rgba(var(--bg-card-rgb), 0.96);
backdrop-filter: blur(18px) saturate(1.15);
-webkit-backdrop-filter: blur(18px) saturate(1.15);
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-bottom: 0;
box-shadow: 0 -12px 38px rgba(15, 23, 42, 0.14);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
min-height: var(--bottom-bar-height);
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px));
@ -97,7 +98,7 @@
min-height: calc(var(--bottom-bar-height) - 10px);
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0.6rem;
justify-content: space-between;
}
@ -159,12 +160,12 @@
}
.global-bottom-bar .bb-sheet-toggle {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: transparent;
border: 1px solid rgba(15, 76, 117, 0.18);
background: var(--accent-light);
color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
border-radius: 10px;
width: 38px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
@ -173,7 +174,8 @@
transition: all 0.2s ease;
}
.global-bottom-bar .bb-sheet-toggle:hover {
background: var(--accent-light);
background: var(--accent);
color: #fff;
transform: translateY(-1px);
}
.global-bottom-bar .bb-sheet-toggle span { display: none; }
@ -183,10 +185,10 @@
}
.global-bottom-bar .bb-action-btn {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
background: var(--bg-card);
color: var(--text-primary);
border-radius: 999px;
border-radius: 9px;
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
@ -244,12 +246,12 @@
}
.global-bottom-bar .bb-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--accent-light);
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
background: rgba(var(--text-primary-rgb), 0.045);
color: var(--text-primary);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.8rem;
border-radius: 10px;
padding: 0.34rem 0.65rem;
font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
@ -379,21 +381,21 @@
}
.global-bottom-bar.is-expanded .bb-sheet-panel {
max-height: min(52vh, 460px);
max-height: min(var(--bottom-bar-expanded-height), 590px);
opacity: 1;
}
.global-bottom-bar .bb-sheet-inner {
background: var(--bg-card);
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 14px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
min-height: 240px;
max-height: min(52vh, 420px);
height: min(52vh, 420px);
border-radius: 16px;
display: flex;
flex-direction: row;
min-height: 320px;
max-height: min(var(--bottom-bar-expanded-height), 540px);
height: min(var(--bottom-bar-expanded-height), 540px);
overflow: hidden;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
margin-top: 0.5rem;
}
.global-bottom-bar .bb-sheet-inner > * {
@ -401,30 +403,39 @@
}
.global-bottom-bar .bb-side-tabs {
width: 176px;
flex: 0 0 176px;
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: rgba(var(--text-primary-rgb), 0.03);
padding: 0.75rem 0.5rem;
display: grid;
gap: 0.4rem;
align-content: start;
border-bottom: 0;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
padding: 0.85rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: stretch;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
.global-bottom-bar .bb-side-tabs::-webkit-scrollbar { display: none; }
.global-bottom-bar .bb-tab-btn {
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
border-radius: 9px;
text-align: left;
font-size: 0.85rem;
font-size: 0.84rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
padding: 0.62rem 0.7rem;
line-height: 1.3;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
flex: 0 0 auto;
width: 100%;
}
.global-bottom-bar .bb-tab-btn i {
font-size: 1rem;
@ -453,14 +464,14 @@
}
.global-bottom-bar .bb-tab-btn.is-active {
background: var(--bg-card);
color: var(--accent);
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
background: var(--accent);
color: #fff;
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.22);
font-weight: 700;
}
.global-bottom-bar .bb-tab-btn.is-active i {
opacity: 1;
color: var(--accent);
color: inherit;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-tab-btn .bb-tab-badge {
background: rgba(255, 138, 148, 0.18);
@ -468,48 +479,89 @@
}
.global-bottom-bar .bb-tab-content {
padding: 0.75rem 0.9rem 0.9rem;
padding: 1rem 1.15rem 1.1rem;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1 1 0;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
}
.global-bottom-bar .bb-tab-title {
font-size: 1rem;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
margin-bottom: 0.15rem;
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.15;
}
.global-bottom-bar .bb-tab-heading { flex: 0 0 auto; padding-bottom: 0.75rem; margin-bottom: 0.75rem; border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08); }
.global-bottom-bar .bb-tab-description { color: var(--text-secondary); font-size: 0.78rem; line-height: 1.35; }
.global-bottom-bar .bb-tab-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
gap: 0.45rem;
}
.global-bottom-bar .bb-tab-list li {
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
padding: 0.65rem 0.85rem;
border: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: color-mix(in srgb, var(--bg-card) 96%, var(--text-primary));
border-radius: 11px;
padding: 0.68rem 0.78rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
box-shadow: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.global-bottom-bar .bb-tab-content .bb-tab-list { overflow-y: auto; min-height: 0; padding-right: 0.25rem; }
.global-bottom-bar .bb-task-actions { display: flex; justify-content: flex-end; margin: 0 0 0.75rem !important; width: 100%; }
.global-bottom-bar .bb-task-actions .btn { width: auto !important; box-shadow: none !important; }
.global-bottom-bar .bb-boss-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 0.9rem;
border-radius: 12px;
color: #fff;
background: linear-gradient(120deg, #0f4c75, #176b87);
}
.global-bottom-bar .bb-boss-eyebrow { font-size: 0.72rem; opacity: 0.8; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.global-bottom-bar .bb-boss-heading { font-weight: 700; font-size: 0.95rem; margin-top: 0.1rem; }
.global-bottom-bar .bb-boss-hero .btn { background: #fff; color: #0f4c75; border: 0; white-space: nowrap; }
.global-bottom-bar .bb-boss-kpis .border { border-color: rgba(var(--text-primary-rgb), 0.08) !important; border-radius: 10px !important; }
.global-bottom-bar .bb-support-queue-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.7rem;
border: 1px solid rgba(245, 158, 11, 0.28);
border-left: 4px solid #f59e0b;
border-radius: 10px;
background: rgba(245, 158, 11, 0.055);
margin-bottom: 0.45rem;
}
.global-bottom-bar .bb-support-queue-main { min-width: 0; }
.global-bottom-bar .bb-support-queue-main strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-bottom-bar .bb-support-queue-actions { display: flex; align-items: center; gap: 0.35rem; flex: 0 0 auto; }
.global-bottom-bar .bb-support-queue-actions select { width: 175px; }
.global-bottom-bar .bb-boss-empty { padding: 0.75rem; border-radius: 10px; color: #146c43; background: rgba(25, 135, 84, 0.1); font-weight: 600; }
.global-bottom-bar .bb-messages-layout {
display: flex;
flex-direction: column;
@ -520,7 +572,7 @@
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.1rem;
padding: 0.1rem 0 0.45rem;
scrollbar-width: none;
}
.global-bottom-bar .bb-message-threads::-webkit-scrollbar {
@ -562,7 +614,8 @@
color: #fff;
}
.global-bottom-bar .bb-messages-list {
max-height: min(26vh, 240px);
max-height: none;
flex: 1 1 auto;
overflow-y: auto;
padding-right: 0.2rem;
}
@ -570,10 +623,16 @@
flex: 1 1 auto;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.global-bottom-bar #bbTabInnerContent > * { width: 100%; min-width: 0; }
.global-bottom-bar .bb-panel-overview,
.global-bottom-bar .bb-panel-tasks,
.global-bottom-bar .bb-panel-boss { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); align-content: start; }
.global-bottom-bar .bb-panel-timer { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-content: start; }
.global-bottom-bar .bb-notes-layout {
display: flex;
flex-direction: column;
@ -583,23 +642,44 @@
overflow: hidden;
}
.global-bottom-bar .bb-notes-editor {
flex: 0 0 auto;
min-width: 0;
}
.global-bottom-bar .bb-notes-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.global-bottom-bar .bb-messages-composer {
border-top: 1px solid rgba(var(--text-primary-rgb), 0.08);
padding-top: 0.55rem;
border: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
border-radius: 12px;
padding: 0.65rem;
margin-top: 0.15rem;
}
.global-bottom-bar .bb-messages-layout {
height: 100%;
overflow: hidden;
}
.global-bottom-bar .bb-messages-list li { border: 0; background: transparent; padding: 0.2rem 0.1rem; }
.global-bottom-bar .bb-messages-list li:hover { transform: none; box-shadow: none; }
.global-bottom-bar .bb-notes-editor > li { border: 0; background: transparent; padding: 0; }
.global-bottom-bar .bb-notes-editor > li > .border { border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent) !important; border-radius: 12px !important; background: color-mix(in srgb, var(--accent) 4%, var(--bg-card)) !important; padding: 0.8rem !important; margin: 0 !important; }
.global-bottom-bar .bb-boss-kpis { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); }
.global-bottom-bar .bb-boss-kpis > [class*="col-"] { width: auto; }
.global-bottom-bar .bb-panel-timer li { border-color: rgba(25, 135, 84, 0.18); background: rgba(25, 135, 84, 0.045); }
.global-bottom-bar .bb-timer-case-link { cursor: pointer; }
.global-bottom-bar .bb-timer-case-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
.global-bottom-bar .bb-timer-work-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.3rem; overflow-y: auto; min-height: 0; }
.global-bottom-bar .bb-timer-work-card { display: flex; align-items: center; justify-content: space-between; gap: 0.55rem; min-height: 38px; padding: 0.28rem 0.45rem; border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 8px; background: var(--bg-card); cursor: pointer; font-size: 0.72rem; }
.global-bottom-bar .bb-timer-work-main { display: flex; align-items: center; gap: 0.45rem; min-width: 0; flex: 1 1 auto; white-space: nowrap; overflow: hidden; }
.global-bottom-bar .bb-timer-work-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.76rem; }
.global-bottom-bar .bb-timer-work-meta { color: var(--text-secondary); flex: 0 0 auto; font-size: 0.68rem; white-space: nowrap; }
.global-bottom-bar .bb-timer-work-actions { display: flex; gap: 0.25rem; flex: 0 0 auto; }
.global-bottom-bar .bb-timer-work-card .btn { --bs-btn-padding-y: 0.18rem; --bs-btn-padding-x: 0.4rem; --bs-btn-font-size: 0.68rem; }
.global-bottom-bar .bb-panel-tasks li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.global-bottom-bar .bb-panel-empty { min-height: 140px; display: grid; place-items: center; text-align: center; color: var(--text-secondary); }
.global-bottom-bar .bb-panel-empty i { display: block; font-size: 1.8rem; color: color-mix(in srgb, var(--accent) 55%, var(--text-secondary)); margin-bottom: 0.35rem; }
.global-bottom-bar .bb-live-indicator { width: 0.58rem; height: 0.58rem; border-radius: 50%; background: #198754; box-shadow: 0 0 0 5px rgba(25, 135, 84, 0.12); display: inline-block; margin-right: 0.55rem; }
.global-bottom-bar .bb-detail-line {
min-height: 28px;
padding: 0.2rem 0.2rem 0;
@ -621,6 +701,42 @@
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08);
}
#bbSwitchCaseModal .modal-dialog { max-width: 1080px; }
#bbSwitchCaseModal .modal-body { padding: 1rem 1.15rem 1.2rem; }
#bbSwitchCaseModal .bb-switch-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.8rem; margin-bottom: 1rem; border: 1px solid rgba(var(--text-primary-rgb), 0.08); border-radius: 12px; background: color-mix(in srgb, var(--accent) 4%, var(--bg-card)); }
#bbSwitchCaseModal .bb-switch-search { max-width: 360px; }
#bbSwitchCaseModal .bb-switch-section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); }
#bbSwitchCaseModal .bb-switch-timers { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; margin-bottom: 1rem; }
#bbSwitchCaseModal .bb-switch-timer, #bbSwitchCaseModal .bb-switch-case { border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 11px; background: var(--bg-card); }
#bbSwitchCaseModal .bb-switch-timer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.7rem; }
#bbSwitchCaseModal .bb-switch-timer-info { display: flex; align-items: center; gap: 0.55rem; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-info-title { min-width: 120px; max-width: 34%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-info-meta { color: var(--text-secondary); font-size: 0.72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-status { font-size: 0.66rem; font-weight: 600; }
#bbSwitchCaseModal .bb-switch-timer-pending { width: 100%; min-height: 38px; padding: 0.28rem 0.4rem; gap: 0.35rem; border-left: 2px solid var(--bs-info); font-size: 0.72rem; }
#bbSwitchCaseModal .bb-switch-timer-main { display: flex; align-items: center; gap: 0.35rem; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-timer-title { color: var(--text-primary); font-size: 0.72rem; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-timer-meta { flex: 0 0 auto; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-timer-pending .bb-switch-case-actions { gap: 0.25rem; }
#bbSwitchCaseModal .bb-switch-timer-pending .btn { --bs-btn-padding-y: 0.18rem; --bs-btn-padding-x: 0.38rem; --bs-btn-font-size: 0.68rem; }
#bbSwitchCaseModal .bb-switch-cases { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
#bbSwitchCaseModal .bb-switch-case { display: flex; align-items: center; gap: 0.75rem; padding: 0.72rem; min-width: 0; }
#bbSwitchCaseModal .bb-switch-case-main { flex: 1 1 auto; min-width: 0; }
#bbSwitchCaseModal .bb-switch-case-title { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-case-meta { color: var(--text-secondary); font-size: 0.75rem; margin-top: 0.15rem; }
#bbSwitchCaseModal .bb-switch-case-actions { display: flex; gap: 0.4rem; flex: 0 0 auto; }
#bbSwitchCaseModal .bb-switch-case-actions .btn { white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-empty { grid-column: 1 / -1; padding: 1.4rem; text-align: center; color: var(--text-secondary); border: 1px dashed rgba(var(--text-primary-rgb), 0.14); border-radius: 11px; }
#bbSwitchCaseModal .bb-convert-panel { padding: 0.9rem; border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--accent) 6%, var(--bg-card)); }
#bbSwitchCaseModal.is-converting .bb-switch-search,
#bbSwitchCaseModal.is-converting #bbSwitchTimerActions,
#bbSwitchCaseModal.is-converting .bb-switch-section-title,
#bbSwitchCaseModal.is-converting #bbSwitchTimersList,
#bbSwitchCaseModal.is-converting #bbSwitchRecentCasesList { display: none !important; }
#bbSwitchCaseModal.is-converting .bb-switch-toolbar { grid-template-columns: 1fr; }
#bbSwitchCaseModal.is-converting .modal-dialog { max-width: 980px; }
#bbSwitchCaseModal.is-converting .modal-body { padding-bottom: 1.5rem; }
#bbSwitchCaseModal .list-group-item {
border-color: rgba(var(--text-primary-rgb), 0.08);
transition: background-color 0.2s ease;
@ -651,8 +767,8 @@
}
.global-bottom-bar .bb-sheet-inner {
grid-template-columns: 1fr;
min-height: 240px;
flex-direction: column;
}
.global-bottom-bar.is-expanded .bb-header {
@ -668,11 +784,22 @@
}
.global-bottom-bar .bb-side-tabs {
width: 100%;
flex: 0 0 auto;
flex-direction: row;
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
grid-template-columns: repeat(5, minmax(90px, 1fr));
overflow-x: auto;
}
.global-bottom-bar .bb-tab-btn { width: auto; }
.global-bottom-bar .bb-notes-layout { grid-template-columns: 1fr; overflow-y: auto; }
.global-bottom-bar .bb-boss-kpis { grid-template-columns: repeat(2, 1fr); }
.global-bottom-bar .bb-support-queue-card { align-items: stretch; flex-direction: column; }
.global-bottom-bar .bb-support-queue-actions { width: 100%; }
.global-bottom-bar .bb-support-queue-actions select { width: auto; flex: 1 1 auto; }
#bbSwitchCaseModal .bb-switch-toolbar { align-items: stretch; flex-direction: column; }
#bbSwitchCaseModal .bb-switch-search { max-width: none; }
#bbSwitchCaseModal .bb-switch-cases { grid-template-columns: 1fr; }
}
.navbar {
@ -937,6 +1064,7 @@
<li><h6 class="dropdown-header">Struktur</h6></li>
<li data-menu-key="menu-crm-links"><a class="dropdown-item py-2" href="/links">Links</a></li>
<li data-menu-key="menu-crm-locations"><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
<li data-menu-key="menu-crm-cross-fields"><a class="dropdown-item py-2" href="/app/locations/cross-fields">Krydsfelter</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Pipeline</h6></li>
<li data-menu-key="menu-crm-opportunities"><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
@ -1013,6 +1141,8 @@
<i class="bi bi-clock-history me-2"></i>Data migration
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li data-menu-key="menu-datamigration-migration-center"><a class="dropdown-item py-2" href="/migration-center"><i class="bi bi-arrow-left-right me-2"></i>Migreringscenter</a></li>
<li><hr class="dropdown-divider"></li>
<li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
@ -1136,6 +1266,19 @@
</div>
</div>
<!-- Case Results -->
<div id="caseResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-card-checklist me-2"></i>Sager
</h6>
<a href="/sag" class="btn btn-sm btn-outline-primary">
<i class="bi bi-list-check me-1"></i>Alle sager
</a>
</div>
<div class="result-items"></div>
</div>
<!-- Email Results -->
<div id="emailResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
@ -1316,10 +1459,13 @@
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
<!-- Vises kun for chefer, men her i markup -->
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-person-workspace"></i> Chef</button>
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-diagram-3"></i> Sagsfordeling</button>
</div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
<div class="bb-tab-heading">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
<div id="bbTabDescription" class="bb-tab-description">Det vigtigste samlet ét sted.</div>
</div>
<div id="bbTabInnerContent">
<ul id="bbTabList" class="bb-tab-list">
<li>Venter på data...</li>
@ -1331,14 +1477,17 @@
</div>
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel">
<div class="modal-dialog modal-dialog-scrollable modal-lg modal-dialog-bottom">
<div class="modal-dialog modal-dialog-scrollable modal-xl modal-dialog-bottom">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbSwitchCaseStatus" class="small text-muted mb-3">Henter data...</div>
<div class="bb-switch-toolbar">
<div id="bbSwitchCaseStatus" class="small text-muted">Henter data...</div>
<div class="input-group input-group-sm bb-switch-search"><span class="input-group-text"><i class="bi bi-search"></i></span><input id="bbSwitchCaseSearch" type="search" class="form-control" placeholder="Søg efter sag eller nummer" aria-label="Søg efter sag"></div>
</div>
<div id="bbSwitchTimerActions" class="mb-3 d-none">
<div class="fw-semibold mb-2">Aktiv timer fundet</div>
@ -1349,20 +1498,23 @@
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<h6 class="mb-2">Dine aktive/pausede timere</h6>
<div id="bbSwitchTimersList" class="list-group small">
<div class="list-group-item text-muted">Henter timere...</div>
</div>
<div id="bbConvertTimePanel" class="bb-convert-panel d-none mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><div class="fw-bold">Konverter afsluttet tid</div><div id="bbConvertTimeName" class="bb-switch-timer-info small mt-1"></div></div>
<button type="button" class="btn-close" data-bb-cancel-convert aria-label="Luk"></button>
</div>
<div class="col-12 col-lg-6">
<h6 class="mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="list-group small">
<div class="list-group-item text-muted">Henter sager...</div>
</div>
<div class="row g-2">
<div class="col-6 col-lg-3"><label class="form-label small">Arbejdstype</label><select id="bbConvertWorkType" class="form-select form-select-sm"><option value="support">Support</option><option value="troubleshooting">Fejlsøgning</option><option value="development">Udvikling</option><option value="maintenance">Vedligehold</option><option value="on_site">Kørsel / On-site</option><option value="meeting">Møde</option><option value="other">Andet</option></select></div>
<div class="col-6 col-lg-3"><label class="form-label small">Fakturerbare minutter</label><input id="bbConvertMinutes" type="number" min="0" step="1" class="form-control form-control-sm"></div>
<div class="col-12 col-lg-4"><label class="form-label small">Afregning</label><select id="bbConvertBillingMethod" class="form-select form-select-sm"></select><div id="bbConvertRecommendation" class="form-text"></div></div>
<div class="col-12 col-lg-2 d-flex align-items-end"><button id="bbConvertSubmit" type="button" class="btn btn-primary btn-sm w-100"><i class="bi bi-check2-circle me-1"></i>Konverter tid</button></div>
</div>
</div>
<h6 class="bb-switch-section-title mb-2">Timere på dine sager</h6>
<div id="bbSwitchTimersList" class="bb-switch-timers small"><div class="bb-switch-empty">Henter timere...</div></div>
<h6 class="bb-switch-section-title mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="bb-switch-cases small"><div class="bb-switch-empty">Henter sager...</div></div>
</div>
</div>
</div>
@ -1434,7 +1586,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></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.43"></script>
<script src="/static/js/bottom-bar.js?v=2.64"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true;
@ -1739,6 +1891,7 @@ if (bmcOriginalFetch) {
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('caseResults')) document.getElementById('caseResults').style.display = 'none';
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
@ -2006,6 +2159,50 @@ if (bmcOriginalFetch) {
console.log('Contacts search not available');
}
// Search cases, including linked tags and buzzwords
try {
const casesResponse = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`);
const cases = await casesResponse.json();
const caseResults = document.getElementById('caseResults');
if (Array.isArray(cases) && cases.length > 0) {
hasResults = true;
caseResults.style.display = 'block';
const caseList = caseResults.querySelector('.result-items');
caseList.innerHTML = cases.slice(0, 10).map(item => {
const buzzwords = Array.isArray(item.buzzwords)
? item.buzzwords.filter(Boolean)
: [];
const buzzwordHtml = buzzwords.length
? ` • <i class="bi bi-lightbulb ms-1"></i> ${buzzwords.map(word => escapeHtml(word)).join(', ')}`
: '';
const archivedHtml = item.is_archived
? ' • <span class="badge bg-secondary">Arkiveret</span>'
: '';
return `
<div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;">
<div>
<div class="fw-bold">#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}</div>
<div class="small text-muted">
<i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')}
${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
${buzzwordHtml}
${archivedHtml}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`;
}).join('');
} else if (caseResults) {
caseResults.style.display = 'none';
}
} catch (e) {
console.log('Case and buzzword search not available');
const caseResults = document.getElementById('caseResults');
if (caseResults) caseResults.style.display = 'none';
}
// Search emails
try {
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
@ -2350,6 +2547,11 @@ if (bmcOriginalFetch) {
<label class="form-label">Email override</label>
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
</div>
<div class="mb-3">
<label class="form-label">Mattermost-brugernavn</label>
<input type="text" class="form-control" id="pref_mattermost_username" placeholder="Tomt = BMC Hub-brugernavn">
<div class="form-text">Bruges til direkte beskeder som @brugernavn.</div>
</div>
<button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button>
</div>
</div>
@ -2432,6 +2634,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
{ key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' },
{ key: 'menu-datamigration-migration-center', label: 'Data migration: Migreringscenter' },
{ key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' },
{ key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' },
{ key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' },
@ -2544,6 +2747,7 @@ if (bmcOriginalFetch) {
document.getElementById('pref_notify_email').checked = !!prefs.notify_email;
document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost;
document.getElementById('pref_email_override').value = prefs.email_override || '';
document.getElementById('pref_mattermost_username').value = prefs.mattermost_username || '';
} catch (e) {
console.error('Failed to load reminder preferences', e);
}
@ -2554,7 +2758,8 @@ if (bmcOriginalFetch) {
notify_frontend: document.getElementById('pref_notify_frontend').checked,
notify_email: document.getElementById('pref_notify_email').checked,
notify_mattermost: document.getElementById('pref_notify_mattermost').checked,
email_override: document.getElementById('pref_email_override').value || null
email_override: document.getElementById('pref_email_override').value || null,
mattermost_username: document.getElementById('pref_mattermost_username').value.trim().replace(/^@/, '') || null
};
try {

View File

@ -73,7 +73,7 @@
<tbody id="subscriptionsBody">
<tr>
<td colspan="9" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
</td>
</tr>
</tbody>
@ -539,7 +539,7 @@ async function loadSubscriptions() {
document.getElementById('subscriptionsBody').innerHTML = `
<tr><td colspan="9" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
@ -820,9 +820,9 @@ function formatInterval(interval) {
const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Maaned',
'monthly': 'Måned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
'yearly': 'År'
};
return map[interval] || interval || '-';
}

View File

@ -2,7 +2,7 @@
Subscriptions Frontend Views
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import logging
@ -25,3 +25,19 @@ async def subscriptions_simply_imports(request: Request):
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
"request": request
})
@router.get("/subscriptions/{subscription_id}")
async def subscription_detail_redirect(subscription_id: int):
"""Compatibility detail URL: subscriptions are edited on their associated case."""
from app.core.database import execute_query_single
subscription = execute_query_single(
"SELECT id, sag_id FROM sag_subscriptions WHERE id = %s",
(subscription_id,),
)
if not subscription:
return RedirectResponse(url="/subscriptions", status_code=303)
if subscription.get("sag_id"):
return RedirectResponse(url=f"/sag/{subscription['sag_id']}/v3", status_code=303)
return RedirectResponse(url="/subscriptions", status_code=303)

View File

@ -48,6 +48,7 @@ class WorkType(str, Enum):
"""Type af arbejde"""
SUPPORT = "support"
DEVELOPMENT = "development"
MAINTENANCE = "maintenance"
TROUBLESHOOTING = "troubleshooting"
ON_SITE = "on_site"
MEETING = "meeting"

View File

@ -626,7 +626,7 @@
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
@ -725,6 +725,7 @@
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="maintenance">Vedligehold</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>

View File

@ -372,6 +372,7 @@
<option value="support">Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="maintenance">Vedligehold</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>

View File

@ -116,7 +116,7 @@ class OrderService:
WHERE t.customer_id = %s
AND t.status = 'approved'
AND t.billable = true
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card')
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card', 'subscription', 'fixed_price', 'internal', 'non_billable', 'warranty')
ORDER BY COALESCE(c.id, s.id), t.worked_date
"""
approved_times = execute_query(query, (customer_id,))

View File

@ -78,6 +78,33 @@ def _resolve_target_user_id(current_user: Optional[dict], payload_user_id: Any =
return _resolve_current_user_id(current_user)
def _can_view_all_team_timers(current_user: Optional[dict]) -> bool:
"""Restrict company-wide timer visibility to explicit leadership groups."""
if not current_user:
return False
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
return True
user_id = _resolve_current_user_id(current_user)
if not user_id:
return False
rows = execute_query(
"""
SELECT LOWER(COALESCE(g.name, '')) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,),
) or []
leadership_tokens = ("admin", "manager", "leder", "chef")
return any(
any(token in str(row.get("name") or "") for token in leadership_tokens)
for row in rows
)
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
@ -162,6 +189,48 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
)
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
"""Return valid settlement choices and a deterministic recommendation."""
customer = execute_query_single(
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
(customer_id,),
) or {}
hub_customer_id = customer.get("hub_customer_id")
cards = []
agreements = []
if hub_customer_id:
cards = execute_query(
"""
SELECT id, card_number, remaining_hours, rounding_minutes, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active' AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
ORDER BY expires_at ASC NULLS LAST, created_at ASC
""",
(hub_customer_id,),
) or []
agreements = execute_query(
"""
SELECT id, agreement_number, monthly_hours
FROM customer_fixed_price_agreements
WHERE customer_id = %s AND status = 'active'
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
ORDER BY created_at ASC
""",
(hub_customer_id,),
) or []
if cards:
recommendation = {"method": "prepaid", "prepaid_card_id": cards[0]["id"], "reason": "Kunden har et aktivt klippekort"}
elif agreements:
recommendation = {"method": "subscription", "fixed_price_agreement_id": agreements[0]["id"], "reason": "Kunden har en aktiv aftale"}
else:
recommendation = {"method": "invoice", "reason": "Der er ingen aktiv inkluderet aftale"}
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try:
@ -1811,7 +1880,10 @@ async def list_time_entries(
status: Optional[str] = None,
customer_id: Optional[int] = None,
user_name: Optional[str] = None,
search: Optional[str] = None
search: Optional[str] = None,
work_type: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
):
"""
Hent liste af tidsregistreringer med filtre.
@ -1840,6 +1912,18 @@ async def list_time_entries(
if user_name:
query += " AND t.user_name ILIKE %s"
params.append(f"%{user_name}%")
if work_type:
query += " AND COALESCE(t.work_type, 'support') = %s"
params.append(work_type)
if start_date:
query += " AND DATE(COALESCE(t.worked_date, t.created_at)) >= %s"
params.append(start_date)
if end_date:
query += " AND DATE(COALESCE(t.worked_date, t.created_at)) <= %s"
params.append(end_date)
if search:
query += """ AND (
@ -1850,11 +1934,36 @@ async def list_time_entries(
wildcard = f"%{search}%"
params.extend([wildcard, wildcard, wildcard])
filtered_query = query
filtered_params = list(params)
query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
params.extend([min(max(limit, 1), 10000), max(offset, 0)])
times = execute_query(query, tuple(params))
return {"times": times}
summary = execute_query_single(
f"""
SELECT
COUNT(*)::int AS total_entries,
COALESCE(SUM(t.original_hours), 0)::numeric AS total_hours,
COALESCE(SUM(CASE WHEN t.billable THEN COALESCE(t.approved_hours, t.original_hours, 0) ELSE 0 END), 0)::numeric AS billable_hours,
COUNT(DISTINCT COALESCE(t.medarbejder_id::text, t.user_name))::int AS total_employees,
COUNT(DISTINCT COALESCE(t.sag_id, t.case_id))::int AS total_cases
FROM ({filtered_query}) AS t
""",
tuple(filtered_params),
) or {}
type_rows = execute_query(
f"""
SELECT COALESCE(t.work_type, 'support') AS work_type,
COUNT(*)::int AS entries,
COALESCE(SUM(t.original_hours), 0)::numeric AS hours
FROM ({filtered_query}) AS t
GROUP BY COALESCE(t.work_type, 'support')
ORDER BY hours DESC
""",
tuple(filtered_params),
) or []
return {"times": times, "summary": summary, "by_work_type": type_rows}
except Exception as e:
logger.error(f"Error listing times: {e}")
raise HTTPException(status_code=500, detail=str(e))
@ -2239,6 +2348,7 @@ async def list_time_entries_v1(
sag_id: int = Query(..., gt=0),
day: Optional[date] = Query(None),
medarbejder_id: Optional[int] = Query(None, gt=0),
current_user: Optional[dict] = Depends(get_optional_user),
):
"""List tidsregistreringer for en sag med filtre til timeline UI."""
try:
@ -2255,13 +2365,31 @@ async def list_time_entries_v1(
where_sql = " AND ".join(clauses)
query = f"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
cust.name AS customer_name, primary_contact.contact_name,
u.full_name AS employee_display_name, u.username AS employee_username,
(t.medarbejder_id = %s) AS is_own_timer,
CASE
WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL THEN
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0), 0)
ELSE NULL
END AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers cust ON cust.id = s.customer_id
LEFT JOIN LATERAL (
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
FROM sag_kontakter sk
JOIN contacts cont ON cont.id = sk.contact_id
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE {where_sql}
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC
"""
return execute_query(query, tuple(params))
return execute_query(query, tuple([_resolve_current_user_id(current_user)] + params))
except Exception as e:
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to list time entries")
@ -2303,35 +2431,14 @@ async def start_live_timer_v1(
paused_entry = None
if existing:
actual_minutes = _elapsed_minutes_excluding_pause(existing, now)
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
pause_total_seconds = _pause_total_seconds_at(existing, now)
execute_update(
"""
UPDATE tmodule_times
SET slut_tid = %s,
aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
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,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
entry_status = 'afventer',
status = 'pending'
SET aktiv_timer = FALSE,
paused_at = %s
WHERE id = %s
""",
(
now,
pause_total_seconds,
actual_minutes,
rounded_minutes,
actual_minutes,
rounded_minutes,
existing.get("round_block_min") or 30,
existing["id"],
)
(now, existing["id"])
)
paused_entry = existing["id"]
@ -2353,13 +2460,13 @@ async def start_live_timer_v1(
worked_date, user_name, status, billable,
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret
aktiv_timer, round_block_min, ikke_placeret, work_type
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s
%s, %s, %s, %s
) RETURNING *
""",
(
@ -2382,6 +2489,7 @@ async def start_live_timer_v1(
True,
round_block_min,
False,
payload.get("work_type") or "support",
)
)
@ -2405,13 +2513,16 @@ async def stop_live_timer_v1(
try:
now = datetime.now()
time_id = payload.get("time_id")
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
if time_id:
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
entry = execute_query_single(
"SELECT * FROM tmodule_times WHERE id = %s AND medarbejder_id = %s",
(time_id, bruger_id),
)
else:
if not bruger_id:
raise HTTPException(status_code=400, detail="medarbejder_id could not be resolved")
entry = execute_query_single(
"""
SELECT *
@ -2424,7 +2535,7 @@ async def stop_live_timer_v1(
)
if not entry:
raise HTTPException(status_code=404, detail="No active timer found")
raise HTTPException(status_code=404, detail="Timeren blev ikke fundet eller tilhører en anden medarbejder")
actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None:
@ -2615,7 +2726,7 @@ async def resume_live_timer_v1(
async def list_my_switchable_timers_v1(
current_user: Optional[dict] = Depends(get_optional_user)
):
"""List authenticated user's currently active and paused timers for switch-case UI."""
"""List authenticated user's active, paused and recently stopped timers for switch-case UI."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
@ -2623,8 +2734,24 @@ async def list_my_switchable_timers_v1(
active = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
cust.name AS customer_name, primary_contact.contact_name,
u.full_name AS employee_display_name, u.username AS employee_username,
GREATEST(
EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0),
0
) AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers cust ON cust.id = s.customer_id
LEFT JOIN LATERAL (
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
FROM sag_kontakter sk
JOIN contacts cont ON cont.id = sk.contact_id
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
@ -2637,8 +2764,13 @@ async def list_my_switchable_timers_v1(
paused = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
SELECT t.*, s.titel AS sag_navn, u.full_name AS employee_display_name, u.username AS employee_username,
GREATEST(
EXTRACT(EPOCH FROM (t.paused_at - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0),
0
) AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
@ -2649,9 +2781,38 @@ async def list_my_switchable_timers_v1(
(bruger_id,),
)
stopped = execute_query(
"""
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
cust.name AS customer_name, primary_contact.contact_name,
u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers cust ON cust.id = s.customer_id
LEFT JOIN LATERAL (
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
FROM sag_kontakter sk
JOIN contacts cont ON cont.id = sk.contact_id
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NOT NULL
AND t.aktiv_timer = FALSE
AND COALESCE(t.entry_status, 'afventer') <> 'godkendt'
AND COALESCE(t.status, 'pending') = 'pending'
ORDER BY t.slut_tid DESC NULLS LAST, t.id DESC
LIMIT 25
""",
(bruger_id,),
)
return {
"active": active or [],
"paused": paused or [],
"stopped": stopped or [],
}
except HTTPException:
raise
@ -2660,6 +2821,49 @@ async def list_my_switchable_timers_v1(
raise HTTPException(status_code=500, detail="Failed to list switchable timers")
@router.get("/time/team-status", tags=["Internal"])
async def list_team_timer_status_v1(
scope: str = Query("mine", pattern="^(mine|all)$"),
current_user: Optional[dict] = Depends(get_optional_user),
):
"""Timer work queue for the bottom bar, optionally across all employees."""
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
if scope == "all" and not _can_view_all_team_timers(current_user):
raise HTTPException(status_code=403, detail="Du har ikke rettighed til at se alle medarbejderes timere")
employee_filter = "AND t.medarbejder_id = %s" if scope == "mine" else ""
params = (bruger_id,) if scope == "mine" else ()
rows = execute_query(
f"""
SELECT t.*, s.titel AS sag_navn,
COALESCE(u.full_name, u.username, t.user_name, 'Ukendt medarbejder') AS employee_display_name,
(t.medarbejder_id = %s) AS is_own_timer,
CASE
WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL THEN 'active'
WHEN t.paused_at IS NOT NULL AND t.slut_tid IS NULL THEN 'paused'
ELSE 'pending_conversion'
END AS timer_state,
CASE WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL
THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0), 0)
ELSE NULL END AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE (
(t.slut_tid IS NULL AND (t.aktiv_timer = TRUE OR t.paused_at IS NOT NULL))
OR (t.slut_tid IS NOT NULL AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending')
)
{employee_filter}
ORDER BY CASE WHEN t.aktiv_timer THEN 0 WHEN t.slut_tid IS NULL THEN 1 ELSE 2 END,
COALESCE(t.updated_at, t.created_at) DESC
LIMIT 100
""",
tuple([bruger_id] + list(params)),
) or []
return {"scope": scope, "rows": rows}
@router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...),
@ -2723,7 +2927,7 @@ async def create_manual_time_v1(
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret,
approved_hours, rounded_to
approved_hours, rounded_to, work_type
) VALUES (
%s, %s, %s, %s,
%s, %s, %s,
@ -2731,7 +2935,7 @@ async def create_manual_time_v1(
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s,
%s, %s
%s, %s, %s
) RETURNING *
"""
@ -2761,6 +2965,7 @@ async def create_manual_time_v1(
not_placed,
(billable_minutes / 60.0) if billable else None,
(round_block_min / 60.0) if billable else None,
payload.get("work_type") or "support",
)
)
return inserted[0] if inserted else None
@ -2787,7 +2992,7 @@ async def patch_time_entry_v1(
updates: Dict[str, Any] = {}
allowed_direct = [
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id"
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id", "work_type"
]
for key in allowed_direct:
if key in payload:
@ -2850,6 +3055,15 @@ async def patch_time_entry_v1(
raise HTTPException(status_code=500, detail="Failed to patch time entry")
@router.get("/time/{time_id}/settlement-options", tags=["Internal"])
async def get_time_settlement_options_v1(time_id: int):
"""Show valid invoice, prepaid and agreement choices before approval."""
entry = execute_query_single("SELECT id, customer_id FROM tmodule_times WHERE id = %s", (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
return _settlement_options_for_customer(entry.get("customer_id"))
@router.post("/time/{time_id}/approve", tags=["Internal"])
async def approve_time_entry_v1(
time_id: int,
@ -2862,16 +3076,35 @@ async def approve_time_entry_v1(
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
current_user_id = _resolve_current_user_id(current_user)
if not current_user_id:
raise HTTPException(status_code=401, detail="Authentication required")
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if int(entry.get("medarbejder_id") or 0) != int(current_user_id) and not is_admin_approver:
raise HTTPException(status_code=403, detail="Du kan kun registrere dine egne timere")
_assert_prepaid_entry_editable(entry)
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
work_type = str(payload.get("work_type") or entry.get("work_type") or "support")
if work_type not in {"support", "troubleshooting", "development", "maintenance", "on_site", "meeting", "other"}:
raise HTTPException(status_code=400, detail="Invalid work_type")
if entry_type == "ukendt":
if not is_admin_approver:
raise HTTPException(status_code=400, detail="entry_type is required before approval")
logger.warning("⚠️ Admin approved time entry with ukendt type (time_id=%s)", time_id)
billable = bool(payload.get("fakturerbar", entry.get("billable", True)))
options = _settlement_options_for_customer(entry.get("customer_id"))
recommendation = options.get("recommended") or {"method": "invoice"}
billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower()
if billing_method == "fixed_price":
billing_method = "subscription"
if billing_method not in {"invoice", "prepaid", "subscription", "internal", "non_billable"}:
raise HTTPException(status_code=400, detail="Invalid billing_method")
prepaid_card_id = payload.get("prepaid_card_id") or recommendation.get("prepaid_card_id")
agreement_id = payload.get("fixed_price_agreement_id") or recommendation.get("fixed_price_agreement_id")
billable = billing_method in {"invoice", "prepaid"}
billed_minutes = payload.get("fakturerbar_tid_min")
if billed_minutes is None:
billed_minutes = entry.get("fakturerbar_tid_min")
@ -2880,16 +3113,48 @@ async def approve_time_entry_v1(
billed_minutes = _round_up_minutes(faktisk, int(entry.get("round_block_min") or 30))
billed_minutes = int(billed_minutes)
final_status = "approved" if billing_method == "invoice" else "billed"
if billing_method == "prepaid":
valid_card_ids = {int(card["id"]) for card in options.get("prepaid_cards", [])}
if not prepaid_card_id or int(prepaid_card_id) not in valid_card_ids:
raise HTTPException(status_code=409, detail="Vælg et aktivt klippekort for kunden")
card = next(card for card in options["prepaid_cards"] if int(card["id"]) == int(prepaid_card_id))
rounding = max(1, int(card.get("rounding_minutes") or entry.get("round_block_min") or 30))
billed_minutes = _round_up_minutes(billed_minutes, rounding)
hours = billed_minutes / 60.0
debited = execute_query(
"""
UPDATE tticket_prepaid_cards
SET used_hours = used_hours + %s
WHERE id = %s AND status = 'active' AND remaining_hours >= %s
RETURNING id, remaining_hours
""",
(hours, prepaid_card_id, hours),
)
if not debited:
raise HTTPException(status_code=409, detail="Klippekortet har ikke timer nok")
elif billing_method == "subscription":
valid_agreement_ids = {int(agreement["id"]) for agreement in options.get("agreements", [])}
if not agreement_id or int(agreement_id) not in valid_agreement_ids:
raise HTTPException(status_code=409, detail="Vælg en aktiv aftale for kunden")
billable = False
elif billing_method in {"internal", "non_billable"}:
billable = False
approved_by = _resolve_current_user_id(current_user)
updated = execute_query(
"""
UPDATE tmodule_times
SET entry_type = %s,
work_type = %s,
entry_status = 'godkendt',
status = 'approved',
status = %s,
billable = %s,
billing_method = %s,
prepaid_card_id = %s,
fixed_price_agreement_id = %s,
fakturerbar_tid_min = CASE WHEN %s THEN %s ELSE 0 END,
approved_hours = CASE WHEN %s THEN (%s::numeric / 60.0) ELSE 0 END,
approved_hours = CASE WHEN %s AND %s > 0 THEN (%s::numeric / 60.0) ELSE NULL END,
approved_at = %s,
approved_by = %s,
aktiv_timer = FALSE
@ -2898,11 +3163,17 @@ async def approve_time_entry_v1(
""",
(
entry_type,
work_type,
final_status,
billable,
billing_method,
int(prepaid_card_id) if billing_method == "prepaid" else None,
int(agreement_id) if billing_method == "subscription" else None,
billable,
billed_minutes,
billable,
billed_minutes,
billed_minutes,
datetime.now(),
approved_by,
time_id,

View File

@ -114,7 +114,7 @@
<div class="container-fluid py-4">
<div class="log-hero">
<h1 class="h4 mb-1">Medarbejder Log</h1>
<div>Visualiser registreret tid over dag, uge og maaned, og find manglende tider/sager.</div>
<div>Visualiser registreret tid over dag, uge og måned, og find manglende tider/sager.</div>
</div>
<div class="log-card">
@ -124,7 +124,7 @@
<select id="granularity" class="form-select">
<option value="day">Dag</option>
<option value="week" selected>Uge</option>
<option value="month">Maaned</option>
<option value="month">Måned</option>
</select>
</div>
<div>
@ -136,7 +136,7 @@
<input id="endDate" type="date" class="form-control">
</div>
<div>
<label class="form-label small">Maal timer/dag</label>
<label class="form-label small">Mål: timer/dag</label>
<input id="targetHours" type="number" min="0" max="24" step="0.5" value="7.5" class="form-control">
</div>
<div>

View File

@ -57,6 +57,25 @@
.status-rejected { background: #f8d7da; color: #842029; }
.status-billed { background: #cfe2ff; color: #084298; }
.report-summary {
display: grid;
grid-template-columns: repeat(5, minmax(130px, 1fr));
gap: 0.75rem;
}
.report-summary-card {
padding: 0.9rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.report-summary-card .value { font-size: 1.35rem; font-weight: 700; }
@media (max-width: 992px) {
.report-summary { grid-template-columns: repeat(2, 1fr); }
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
{% endblock %}
@ -69,9 +88,10 @@
<h1 class="mb-1">Tidsregistreringer</h1>
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
</div>
<button class="btn btn-outline-primary" onclick="loadData()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
<div class="d-flex gap-2">
<button class="btn btn-outline-success" onclick="exportReportCsv()"><i class="bi bi-file-earmark-spreadsheet"></i> Eksportér CSV</button>
<button class="btn btn-outline-primary" onclick="loadData()"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
</div>
</div>
<!-- Filters -->
@ -98,9 +118,32 @@
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label>
<input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Arbejdstype</label>
<select id="filter-work-type" class="form-select" onchange="loadData()">
<option value="">Alle typer</option>
<option value="support">Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="maintenance">Vedligehold</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Fra dato</label>
<input type="date" id="filter-start-date" class="form-control" onchange="loadData()">
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Til dato</label>
<input type="date" id="filter-end-date" class="form-control" onchange="loadData()">
</div>
</div>
</div>
<div id="report-summary" class="report-summary mb-4"></div>
<!-- Table -->
<div class="registrations-table">
<div class="table-responsive">
@ -111,6 +154,7 @@
<th style="width: 20%;">Kunde</th>
<th style="width: 25%;">Beskrivelse / Case</th>
<th>Tekniker</th>
<th>Type</th>
<th class="text-center">Timer</th>
<th class="text-center">Fakt.</th>
<th>Status</th>
@ -118,7 +162,7 @@
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="8" class="text-center py-5 text-muted">Henter data...</td></tr>
<tr><td colspan="9" class="text-center py-5 text-muted">Henter data...</td></tr>
</tbody>
</table>
</div>
@ -158,28 +202,36 @@
debounceTimer = setTimeout(loadData, 500);
}
const workTypeLabels = {
support: 'Support', troubleshooting: 'Fejlsøgning', development: 'Udvikling',
maintenance: 'Vedligehold', on_site: 'Kørsel / On-site', meeting: 'Møde', other: 'Andet'
};
function buildReportParams(limit = 100) {
const params = new URLSearchParams({ limit, offset: 0 });
const values = {
search: document.getElementById('filter-search').value,
status: document.getElementById('filter-status').value,
user_name: document.getElementById('filter-user').value,
work_type: document.getElementById('filter-work-type').value,
start_date: document.getElementById('filter-start-date').value,
end_date: document.getElementById('filter-end-date').value
};
Object.entries(values).forEach(([key, value]) => { if (value) params.append(key, value); });
return params;
}
async function loadData() {
const tbody = document.getElementById('table-body');
const search = document.getElementById('filter-search').value;
const status = document.getElementById('filter-status').value;
const user = document.getElementById('filter-user').value;
// Build URL
const params = new URLSearchParams({
limit: 100, // Hardcoded limit for now
offset: 0
});
if (search) params.append('search', search);
if (status) params.append('status', status);
if (user) params.append('user_name', user);
const params = buildReportParams();
try {
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
const data = await response.json();
renderReportSummary(data);
if (data.times.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5">Ingen resultater fundet</td></tr>`;
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5">Ingen resultater fundet</td></tr>`;
return;
}
@ -203,6 +255,7 @@
<td>
${t.user_name || '-'}
</td>
<td><span class="badge bg-secondary">${workTypeLabels[t.work_type || 'support'] || t.work_type || 'Support'}</span></td>
<td class="text-center">
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
</td>
@ -225,10 +278,46 @@
} catch (error) {
console.error(error);
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
}
}
function renderReportSummary(data) {
const summary = data.summary || {};
const cards = [
['Timer', Number(summary.total_hours || 0).toFixed(2)],
['Fakturerbare timer', Number(summary.billable_hours || 0).toFixed(2)],
['Registreringer', summary.total_entries || 0],
['Medarbejdere', summary.total_employees || 0],
['Sager', summary.total_cases || 0]
];
const typeText = (data.by_work_type || [])
.map(row => `${workTypeLabels[row.work_type] || row.work_type}: ${Number(row.hours || 0).toFixed(2)} t`)
.join(' · ');
document.getElementById('report-summary').innerHTML = cards.map(([label, value]) => `
<div class="report-summary-card"><div class="small text-muted">${label}</div><div class="value">${value}</div></div>
`).join('') + (typeText ? `<div class="small text-muted grid-column-span-all" style="grid-column:1/-1">Fordeling: ${typeText}</div>` : '');
}
async function exportReportCsv() {
const response = await fetch(`/api/v1/timetracking/times?${buildReportParams(10000).toString()}`);
const data = await response.json();
if (!response.ok) return alert(data.detail || 'Kunne ikke eksportere rapporten');
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`;
const rows = [['Dato', 'Kunde', 'Sag', 'Medarbejder', 'Type', 'Beskrivelse', 'Timer', 'Fakturerbar', 'Status']];
(data.times || []).forEach(t => rows.push([
t.worked_date, t.customer_name, t.case_title, t.user_name,
workTypeLabels[t.work_type || 'support'] || t.work_type,
t.description, t.original_hours, t.billable ? 'Ja' : 'Nej', t.status
]));
const csv = '\ufeff' + rows.map(row => row.map(quote).join(';')).join('\n');
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
link.download = `tidsrapport-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('da-DK');

View File

@ -126,7 +126,7 @@
<div class="container-fluid py-4">
<div class="report-hero">
<h1>Servicekontrakt Rapport</h1>
<p>Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.</p>
<p>Vælg kunde og servicekontrakt for at se relaterede sager og tidsregistreringer fra vTiger.</p>
</div>
<div class="report-card mb-3">
@ -134,13 +134,13 @@
<div>
<label for="customerSelect" class="form-label fw-semibold">Kunde</label>
<select id="customerSelect" class="form-select">
<option value="">-- Vaelg kunde --</option>
<option value="">-- Vælg kunde --</option>
</select>
</div>
<div>
<label for="contractSelect" class="form-label fw-semibold">Servicekontrakt</label>
<select id="contractSelect" class="form-select" disabled>
<option value="">-- Vaelg servicekontrakt --</option>
<option value="">-- Vælg servicekontrakt --</option>
</select>
</div>
<div class="d-flex gap-2">
@ -155,7 +155,7 @@
</button>
</div>
</div>
<div id="filterHint" class="small text-muted mt-2">Start med at vaelge en kunde.</div>
<div id="filterHint" class="small text-muted mt-2">Start med at vælge en kunde.</div>
</div>
<div id="errorBox" class="alert alert-danger d-none" role="alert"></div>
@ -221,7 +221,7 @@
customerSelect.appendChild(option);
}
} catch (error) {
showError(error.message || 'Fejl ved indlaesning af kunder');
showError(error.message || 'Fejl ved indlæsning af kunder');
}
}
@ -229,14 +229,14 @@
state.selectedAccountId = event.target.value || '';
state.selectedContractId = '';
reportSection.classList.add('d-none');
contractSelect.innerHTML = '<option value="">-- Vaelg servicekontrakt --</option>';
contractSelect.innerHTML = '<option value="">-- Vælg servicekontrakt --</option>';
contractSelect.disabled = !state.selectedAccountId;
loadBtn.disabled = true;
pdfBtn.disabled = true;
excelBtn.disabled = true;
if (!state.selectedAccountId) {
filterHint.textContent = 'Start med at vaelge en kunde.';
filterHint.textContent = 'Start med at vælge en kunde.';
return;
}
@ -260,7 +260,7 @@
}
filterHint.textContent = state.contracts.length
? 'Vaelg servicekontrakt og hent rapporten.'
? 'Vælg servicekontrakt, og hent rapporten.'
: 'Ingen servicekontrakter fundet for kunden.';
} catch (error) {
showError(error.message || 'Fejl ved hentning af servicekontrakter');
@ -312,7 +312,7 @@
document.getElementById('reportContractMeta').textContent = `${customer.account_name} (${customer.account_id})`;
const contractLinkEl = document.getElementById('reportContractLink');
if (contract.vtiger_url) {
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn servicekontrakt i vTiger</a>`;
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Åbn servicekontrakt i vTiger</a>`;
} else {
contractLinkEl.textContent = '';
}
@ -354,7 +354,7 @@
<div class="case-title">${escapeHtml(caseItem.title || '-')}</div>
<div class="small text-muted">CC-nummer: ${escapeHtml(caseItem.cc_number || caseItem.id || '-')}</div>
<div class="small text-muted">Kontaktperson: ${escapeHtml(caseItem.contact_person || '-')}</div>
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn case i vTiger</a>` : ''}</div>
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Åbn sag i vTiger</a>` : ''}</div>
<div class="small mt-1">${escapeHtml(caseItem.description || 'Ingen beskrivelse')}</div>
</div>
<div class="small text-end">

106
app/utils/safe_html.py Normal file
View File

@ -0,0 +1,106 @@
"""Small allow-list sanitizer for HTML rendered inside the BMC Hub UI."""
import html
from html.parser import HTMLParser
from typing import Optional
class _SafeHtmlSanitizer(HTMLParser):
_ALLOWED_TAGS = {
"a", "b", "strong", "i", "em", "u", "s",
"p", "div", "span", "br", "hr", "blockquote", "pre", "code",
"ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
}
_VOID_TAGS = {"br", "hr"}
_DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "svg", "math", "head"}
_ALLOWED_ATTRS = {
"a": {"href", "title"},
"th": {"colspan", "rowspan"},
"td": {"colspan", "rowspan"},
}
def __init__(self):
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._drop_depth = 0
def handle_starttag(self, tag, attrs):
tag = str(tag or "").lower()
if tag in self._DROP_WITH_CONTENT:
self._drop_depth += 1
return
if self._drop_depth or tag not in self._ALLOWED_TAGS:
return
safe_attrs: list[str] = []
for key, value in attrs or []:
key = str(key or "").lower()
if key not in self._ALLOWED_ATTRS.get(tag, set()):
continue
value = str(value or "").strip()
if key == "href":
normalized = value.lower()
if not normalized.startswith(("https://", "http://", "mailto:", "tel:", "/")):
continue
if key in {"colspan", "rowspan"}:
try:
number = int(value)
except (TypeError, ValueError):
continue
if number < 1 or number > 100:
continue
value = str(number)
safe_attrs.append(f'{key}="{html.escape(value, quote=True)}"')
attrs_html = f" {' '.join(safe_attrs)}" if safe_attrs else ""
if tag == "a":
attrs_html += ' target="_blank" rel="noopener noreferrer"'
self._parts.append(f"<{tag}{attrs_html}>")
def handle_startendtag(self, tag, attrs):
if str(tag or "").lower() in self._DROP_WITH_CONTENT:
return
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag):
tag = str(tag or "").lower()
if tag in self._DROP_WITH_CONTENT:
self._drop_depth = max(0, self._drop_depth - 1)
return
if self._drop_depth or tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
return
self._parts.append(f"</{tag}>")
def handle_data(self, data):
if not self._drop_depth:
self._parts.append(html.escape(data or ""))
def handle_entityref(self, name):
if not self._drop_depth:
self._parts.append(f"&{name};")
def handle_charref(self, name):
if not self._drop_depth:
self._parts.append(f"&#{name};")
def get_html(self) -> str:
return "".join(self._parts).strip()
def sanitize_safe_html(value: Optional[str]) -> str:
"""Return safe renderable HTML while preserving ordinary plain text."""
raw = str(value or "").strip()
if not raw:
return ""
if "<" not in raw and ">" not in raw:
return html.escape(raw)
sanitizer = _SafeHtmlSanitizer()
try:
sanitizer.feed(raw)
sanitizer.close()
return sanitizer.get_html()
except Exception:
return html.escape(raw)

View File

@ -55,6 +55,61 @@ class VendorCustomerLinkCreate(BaseModel):
relationship_type: Optional[str] = "supplier"
class EmailDomainCreate(BaseModel):
domain: str
def _normalize_email_domain(value: str) -> str:
domain = str(value or "").strip().lower()
domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.")
domain = domain.split("/", 1)[0].strip(". ")
if not domain or "." not in domain or "@" in domain:
raise HTTPException(status_code=400, detail="Ugyldigt emaildomæne")
return domain
@router.get("/vendors/{vendor_id}/email-domains", tags=["Vendors"])
async def list_vendor_email_domains(vendor_id: int):
return execute_query(
"SELECT domain, created_at FROM vendor_email_domains WHERE vendor_id = %s ORDER BY domain",
(vendor_id,),
) or []
@router.post("/vendors/{vendor_id}/email-domains", tags=["Vendors"])
async def add_vendor_email_domain(vendor_id: int, payload: EmailDomainCreate):
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
domain = _normalize_email_domain(payload.domain)
existing = execute_query_single(
"SELECT vendor_id FROM vendor_email_domains WHERE domain = %s",
(domain,),
)
if existing and int(existing["vendor_id"]) != vendor_id:
raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden leverandør")
return execute_query_single(
"""
INSERT INTO vendor_email_domains (domain, vendor_id)
VALUES (%s, %s)
ON CONFLICT (domain) DO UPDATE SET vendor_id = EXCLUDED.vendor_id
RETURNING domain, vendor_id, created_at
""",
(domain, vendor_id),
)
@router.delete("/vendors/{vendor_id}/email-domains/{domain}", tags=["Vendors"])
async def delete_vendor_email_domain(vendor_id: int, domain: str):
normalized = _normalize_email_domain(domain)
deleted = execute_update(
"DELETE FROM vendor_email_domains WHERE vendor_id = %s AND domain = %s",
(vendor_id, normalized),
)
if not deleted:
raise HTTPException(status_code=404, detail="Domænet blev ikke fundet")
return {"success": True}
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
@ -99,6 +154,70 @@ async def get_vendor(vendor_id: int):
return result[0]
@router.get("/vendors/{vendor_id}/invoices", tags=["Vendors"])
async def get_vendor_invoices(vendor_id: int):
"""Return booked invoices together with invoices that only exist as extractions."""
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
raise HTTPException(status_code=404, detail="Vendor not found")
rows = execute_query(
"""
WITH booked AS (
SELECT si.id, si.invoice_number, si.invoice_date, si.due_date,
si.total_amount, si.currency,
CASE
WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN si.status
WHEN si.paid_date IS NOT NULL THEN 'paid'
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
ELSE si.status
END AS status,
'supplier_invoice'::text AS source_type,
si.extraction_id, e.file_id, si.created_at AS sort_date
FROM supplier_invoices si
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
WHERE si.vendor_id = %s
),
latest_extractions AS (
SELECT DISTINCT ON (TRIM(e.document_id))
NULL::integer AS id, e.document_id AS invoice_number,
e.document_date AS invoice_date, e.due_date, e.total_amount,
e.currency, COALESCE(run.status, e.status, file.status, 'extracted') AS status,
'extraction'::text AS source_type,
e.extraction_id, e.file_id,
COALESCE(run.processed_at, e.created_at) AS sort_date
FROM extractions e
LEFT JOIN incoming_files file ON file.file_id = e.file_id
LEFT JOIN LATERAL (
SELECT sync.status, sync.processed_at
FROM internet_connections_invoice_sync_runs sync
WHERE sync.extraction_id = e.extraction_id
OR (
sync.invoice_number = e.document_id
AND COALESCE(sync.vendor_name, '') = COALESCE(e.vendor_name, '')
)
ORDER BY sync.processed_at DESC, sync.id DESC
LIMIT 1
) run ON TRUE
WHERE e.vendor_matched_id = %s
AND NULLIF(TRIM(e.document_id), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM supplier_invoices si
WHERE si.vendor_id = %s
AND (si.extraction_id = e.extraction_id OR si.invoice_number = e.document_id)
)
ORDER BY TRIM(e.document_id), e.created_at DESC, e.extraction_id DESC
)
SELECT * FROM booked
UNION ALL
SELECT * FROM latest_extractions
ORDER BY sort_date DESC NULLS LAST, invoice_date DESC NULLS LAST
""",
(vendor_id, vendor_id, vendor_id),
)
return rows or []
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
async def create_vendor(vendor: VendorCreate):
"""Create a new vendor"""

View File

@ -184,6 +184,16 @@
</div>
</div>
</div>
<div class="card p-4 mt-4">
<h5 class="mb-2 fw-bold">Emaildomæner</h5>
<p class="small text-muted">Kun eksakte domæner bruges til automatisk match.</p>
<div class="input-group input-group-sm mb-3">
<input id="vendorEmailDomainInput" class="form-control" placeholder="fx cloudfactorygroup.com">
<button class="btn btn-primary" onclick="addVendorEmailDomain()">Tilføj</button>
</div>
<div id="vendorEmailDomains" class="d-flex flex-wrap gap-2"></div>
</div>
</div>
</div>
</div>
@ -200,7 +210,7 @@
<div class="tab-pane fade" id="fakturaer">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="mb-0 fw-bold">Leverandør Fakturaer</h5>
<h5 class="mb-0 fw-bold">Leverandørfakturaer</h5>
<span class="badge bg-primary" id="invoiceCount">0</span>
</div>
<div class="table-responsive">
@ -372,6 +382,7 @@ async function loadVendor() {
}
const vendor = await response.json();
displayVendor(vendor);
await loadVendorEmailDomains();
await loadVendorCustomers();
} catch (error) {
console.error('Error loading vendor:', error);
@ -379,6 +390,52 @@ async function loadVendor() {
}
}
async function loadVendorEmailDomains() {
const host = document.getElementById('vendorEmailDomains');
if (!host) return;
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains`);
if (!response.ok) throw new Error('Kunne ikke hente domæner');
const rows = await response.json();
host.innerHTML = rows.length ? rows.map(row => `
<span class="badge bg-light text-dark border p-2">
${escapeHtml(row.domain)}
<button class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="deleteVendorEmailDomain('${encodeURIComponent(row.domain)}')" title="Fjern">×</button>
</span>
`).join('') : '<span class="small text-muted">Ingen domæner registreret.</span>';
} catch (error) {
host.innerHTML = `<span class="small text-danger">${escapeHtml(error.message)}</span>`;
}
}
async function addVendorEmailDomain() {
const input = document.getElementById('vendorEmailDomainInput');
const domain = input.value.trim();
if (!domain) return;
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke tilføjes');
return;
}
input.value = '';
await loadVendorEmailDomains();
}
async function deleteVendorEmailDomain(encodedDomain) {
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains/${encodedDomain}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke fjernes');
return;
}
await loadVendorEmailDomains();
}
async function loadVendorCustomers() {
const listEl = document.getElementById('vendorCustomersList');
const emptyEl = document.getElementById('vendorCustomersEmpty');
@ -627,7 +684,7 @@ function displayVendor(vendor) {
async function loadVendorInvoices() {
try {
const response = await fetch(`/api/v1/supplier-invoices?vendor_id=${vendorId}`);
const response = await fetch(`/api/v1/vendors/${vendorId}/invoices`);
if (!response.ok) throw new Error('Failed to load invoices');
const invoices = await response.json();
@ -673,7 +730,9 @@ function displayInvoices(invoices) {
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
<td><span class="badge ${statusClass}">${statusText}</span></td>
<td class="text-end">
<a href="/billing/supplier-invoices?invoice=${invoice.id}" class="btn btn-sm btn-outline-primary">
<a href="${invoice.source_type === 'supplier_invoice'
? `/billing/supplier-invoices?invoice=${invoice.id}`
: '/economy/internet-connections#invoice-sync'}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
@ -688,7 +747,15 @@ function getInvoiceStatusClass(status) {
'paid': 'bg-success',
'overdue': 'bg-danger',
'cancelled': 'bg-secondary',
'pending': 'bg-info'
'credited': 'bg-secondary',
'rejected': 'bg-secondary',
'pending': 'bg-info',
'success': 'bg-success',
'warning': 'bg-warning text-dark',
'error': 'bg-danger',
'extracted': 'bg-info text-dark',
'ai_extracted': 'bg-info text-dark',
'processed': 'bg-success'
};
return classes[status] || 'bg-secondary';
}
@ -699,7 +766,15 @@ function getInvoiceStatusText(status) {
'paid': 'Betalt',
'overdue': 'Forfalden',
'cancelled': 'Annulleret',
'pending': 'Afventer'
'credited': 'Krediteret',
'rejected': 'Afvist',
'pending': 'Afventer',
'success': 'Behandlet',
'warning': 'Kræver kontrol',
'error': 'Fejl',
'extracted': 'Udtrukket',
'ai_extracted': 'Udtrukket',
'processed': 'Behandlet'
};
return texts[status] || status;
}

View File

@ -149,6 +149,8 @@ from app.modules.internet_connections.backend import router as internet_connecti
from app.modules.internet_connections.frontend import views as internet_connections_views
from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
from app.modules.migration_center.backend import router as migration_center_api
from app.modules.migration_center.frontend import views as migration_center_views
from app.bug_reports.backend import router as bug_reports_api
# Configure logging
@ -500,6 +502,7 @@ app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Temp
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@ -539,6 +542,7 @@ app.include_router(manual_views.router, tags=["Frontend"])
app.include_router(drift_views.router, tags=["Frontend"])
app.include_router(internet_connections_views.router, tags=["Frontend"])
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
app.include_router(migration_center_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,7 @@
-- Mark wall outlets and their related patch/switch ports as WAN connections.
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS is_wan BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_wall_outlets_is_wan
ON locations_wall_outlets(is_wan)
WHERE deleted_at IS NULL AND is_wan = TRUE;

View File

@ -0,0 +1,31 @@
-- Permanent audit trail for supplier invoices processed into internet connections.
CREATE TABLE IF NOT EXISTS internet_connections_invoice_sync_runs (
id BIGSERIAL PRIMARY KEY,
file_id INTEGER REFERENCES incoming_files(file_id) ON DELETE SET NULL,
extraction_id INTEGER REFERENCES extractions(extraction_id) ON DELETE SET NULL,
supplier_invoice_id INTEGER REFERENCES supplier_invoices(id) ON DELETE SET NULL,
invoice_number VARCHAR(100),
vendor_name VARCHAR(255),
invoice_date DATE,
status VARCHAR(20) NOT NULL
CHECK (status IN ('success', 'warning', 'skipped', 'error')),
connections_synced INTEGER NOT NULL DEFAULT 0,
connections_created INTEGER NOT NULL DEFAULT 0,
connections_updated INTEGER NOT NULL DEFAULT 0,
ip_ranges_synced INTEGER NOT NULL DEFAULT 0,
total_lines INTEGER NOT NULL DEFAULT 0,
actionable_lines INTEGER NOT NULL DEFAULT 0,
skipped_lines INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
result_json JSONB NOT NULL DEFAULT '{}'::jsonb,
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_processed
ON internet_connections_invoice_sync_runs(processed_at DESC);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_status
ON internet_connections_invoice_sync_runs(status, processed_at DESC);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_invoice
ON internet_connections_invoice_sync_runs(invoice_number, vendor_name);

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS internet_connections_invoice_review_decisions (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES internet_connections_invoice_sync_runs(id) ON DELETE CASCADE,
line_number INTEGER NOT NULL,
action VARCHAR(30) NOT NULL
CHECK (action IN ('ignore', 'link_existing', 'create_separate')),
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
note TEXT,
resolved_by_user_id INTEGER,
resolved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (run_id, line_number)
);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_review_decisions_run
ON internet_connections_invoice_review_decisions(run_id, line_number);

View File

@ -0,0 +1,6 @@
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('mattermost_reminders_enabled', 'false', 'notifications', 'Send reminders to Mattermost', 'boolean', false),
('mattermost_webhook_url', '', 'notifications', 'Mattermost incoming webhook URL', 'string', false),
('mattermost_channel', '', 'notifications', 'Optional Mattermost channel override', 'string', false)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,2 @@
ALTER TABLE user_notification_preferences
ADD COLUMN IF NOT EXISTS mattermost_username VARCHAR(100);

View File

@ -0,0 +1,24 @@
-- Dedicated permissions for the Sag module.
-- Existing group access is preserved by copying equivalent ticket permissions.
INSERT INTO permissions (code, description, category) VALUES
('cases.view', 'Se sager', 'cases'),
('cases.create', 'Opret sager', 'cases'),
('cases.edit', 'Redigér sager, relationer, filer og reminders', 'cases'),
('cases.delete', 'Slet sager', 'cases')
ON CONFLICT (code) DO NOTHING;
WITH permission_map(case_code, ticket_code) AS (
VALUES
('cases.view', 'tickets.view'),
('cases.create', 'tickets.create'),
('cases.edit', 'tickets.edit'),
('cases.delete', 'tickets.delete')
)
INSERT INTO group_permissions (group_id, permission_id)
SELECT DISTINCT gp.group_id, case_permission.id
FROM group_permissions gp
JOIN permissions ticket_permission ON ticket_permission.id = gp.permission_id
JOIN permission_map mapping ON mapping.ticket_code = ticket_permission.code
JOIN permissions case_permission ON case_permission.code = mapping.case_code
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,26 @@
-- Multiple exact sender domains per vendor.
CREATE TABLE IF NOT EXISTS vendor_email_domains (
domain TEXT PRIMARY KEY,
vendor_id INTEGER NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_vendor_email_domains_vendor_id
ON vendor_email_domains(vendor_id);
INSERT INTO vendor_email_domains (domain, vendor_id)
SELECT LOWER(TRIM(domain)), id
FROM vendors
WHERE NULLIF(TRIM(domain), '') IS NOT NULL
ON CONFLICT (domain) DO NOTHING;
-- Also promote the legacy single customer domain into the existing
-- multi-domain mapping table.
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
SELECT LOWER(TRIM(email_domain)), id, 'legacy_customer_domain'
FROM customers
WHERE NULLIF(TRIM(email_domain), '') IS NOT NULL
ON CONFLICT (domain) DO NOTHING;
COMMENT ON TABLE vendor_email_domains IS
'Exact trusted sender domains belonging to vendors; no fuzzy matching.';

View File

@ -0,0 +1,9 @@
INSERT INTO groups (name, description)
SELECT
'Bogholderi',
'Kundehenvendelser og opgaver vedrørende bogholderi og økonomi'
WHERE NOT EXISTS (
SELECT 1
FROM groups
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
);

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS contact_merge_history (
id BIGSERIAL PRIMARY KEY,
target_contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
source_contact_id INTEGER NOT NULL,
source_snapshot JSONB NOT NULL,
moved_relations JSONB NOT NULL DEFAULT '{}'::jsonb,
merged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contact_merge_history_target
ON contact_merge_history(target_contact_id, merged_at DESC);
COMMENT ON TABLE contact_merge_history IS
'Audit trail for destructive contact merges. Source contact snapshots and moved relation counts are retained.';

View File

@ -0,0 +1,185 @@
-- Migration 228: Manual subscription and invoice migration centre.
-- e-conomic data is deliberately referenced from Invoice Error Finder and never copied back.
CREATE TABLE IF NOT EXISTS migration_center_sessions (
id SERIAL PRIMARY KEY,
name VARCHAR(160) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('draft', 'active', 'completed', 'archived')),
economic_import_run_id INTEGER REFERENCES invoice_error_finder_import_runs(id) ON DELETE RESTRICT,
economic_snapshot_at TIMESTAMP,
read_only BOOLEAN NOT NULL DEFAULT FALSE,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS migration_center_source_customers (
id BIGSERIAL PRIMARY KEY,
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply', 'economic')),
source_customer_id VARCHAR(120) NOT NULL,
customer_no VARCHAR(80),
customer_name VARCHAR(255) NOT NULL,
cvr VARCHAR(32),
email VARCHAR(255),
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
snapshot_hash CHAR(64) NOT NULL,
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_system, source_customer_id)
);
CREATE TABLE IF NOT EXISTS migration_center_source_subscriptions (
id BIGSERIAL PRIMARY KEY,
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply')),
source_record_id VARCHAR(120) NOT NULL,
source_customer_id VARCHAR(120),
customer_no VARCHAR(80),
customer_name VARCHAR(255),
product_code VARCHAR(100),
product_name VARCHAR(500) NOT NULL,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
quantity NUMERIC(14,4) NOT NULL DEFAULT 1,
billing_frequency VARCHAR(40),
start_date DATE,
end_date DATE,
active BOOLEAN NOT NULL DEFAULT TRUE,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
snapshot_hash CHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_system, source_record_id)
);
CREATE TABLE IF NOT EXISTS migration_center_session_items (
id BIGSERIAL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES migration_center_sessions(id) ON DELETE CASCADE,
entity_type VARCHAR(20) NOT NULL CHECK (entity_type IN ('subscription', 'invoice_line')),
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply', 'economic')),
source_record_id VARCHAR(180) NOT NULL,
source_customer_id VARCHAR(120),
customer_no VARCHAR(80),
customer_name VARCHAR(255),
product_code VARCHAR(100),
product_name VARCHAR(500) NOT NULL,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
quantity NUMERIC(14,4) NOT NULL DEFAULT 1,
billing_frequency VARCHAR(40),
period_from DATE,
period_to DATE,
invoice_no VARCHAR(80),
invoice_date DATE,
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
source_hash CHAR(64) NOT NULL,
previous_source_payload JSONB,
match_status VARCHAR(30) NOT NULL DEFAULT 'new'
CHECK (match_status IN ('new', 'match_found', 'no_match', 'conflict', 'manual_review', 'source_changed')),
approval_status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (approval_status IN ('pending', 'approved', 'rejected', 'verified', 'ignored')),
hub_status VARCHAR(30) NOT NULL DEFAULT 'not_created'
CHECK (hub_status IN ('not_created', 'ready_for_creation', 'created_in_hub', 'linked_to_existing', 'verified')),
lock_status VARCHAR(25) NOT NULL DEFAULT 'unlocked'
CHECK (lock_status IN ('unlocked', 'locking_pending', 'locked', 'lock_failed')),
match_confidence NUMERIC(5,4),
match_explanation JSONB NOT NULL DEFAULT '[]'::jsonb,
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
hub_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
hub_record_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
suggested_hub_record_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
creation_idempotency_key VARCHAR(120),
ignore_reason TEXT,
manual_note TEXT,
verified_at TIMESTAMP,
verified_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
locked_at TIMESTAMP,
locked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (session_id, entity_type, source_system, source_record_id)
);
ALTER TABLE migration_center_session_items
ADD COLUMN IF NOT EXISTS creation_idempotency_key VARCHAR(120);
CREATE INDEX IF NOT EXISTS idx_mc_items_session_type ON migration_center_session_items(session_id, entity_type);
CREATE INDEX IF NOT EXISTS idx_mc_items_work_queue ON migration_center_session_items(session_id, lock_status, approval_status);
CREATE INDEX IF NOT EXISTS idx_mc_items_customer ON migration_center_session_items(session_id, hub_customer_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_mc_items_creation_idempotency
ON migration_center_session_items(session_id, creation_idempotency_key)
WHERE creation_idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS migration_center_matches (
id BIGSERIAL PRIMARY KEY,
session_item_id BIGINT NOT NULL REFERENCES migration_center_session_items(id) ON DELETE CASCADE,
matched_entity_type VARCHAR(30) NOT NULL,
matched_hub_id INTEGER NOT NULL,
confidence NUMERIC(5,4) NOT NULL,
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
approved BOOLEAN,
approved_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
approved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (session_item_id, matched_entity_type, matched_hub_id)
);
CREATE TABLE IF NOT EXISTS migration_center_lock_operations (
id BIGSERIAL PRIMARY KEY,
session_item_id BIGINT NOT NULL REFERENCES migration_center_session_items(id) ON DELETE CASCADE,
status VARCHAR(25) NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
attempt_no INTEGER NOT NULL DEFAULT 1,
external_system VARCHAR(30),
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
response_payload JSONB,
error_message TEXT,
requested_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS migration_center_audit_log (
id BIGSERIAL PRIMARY KEY,
session_id INTEGER REFERENCES migration_center_sessions(id) ON DELETE SET NULL,
session_item_id BIGINT REFERENCES migration_center_session_items(id) ON DELETE SET NULL,
entity_type VARCHAR(40) NOT NULL,
entity_id VARCHAR(180),
action VARCHAR(80) NOT NULL,
old_value JSONB,
new_value JSONB,
source_hash CHAR(64),
performed_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
ip_address VARCHAR(45),
success BOOLEAN NOT NULL DEFAULT TRUE,
error_message TEXT,
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mc_audit_session_time ON migration_center_audit_log(session_id, performed_at DESC);
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS migration_locked BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS migration_locked_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS migration_locked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS migration_source_item_id BIGINT;
CREATE UNIQUE INDEX IF NOT EXISTS uq_sag_subscription_migration_source
ON sag_subscriptions(migration_source_item_id)
WHERE migration_source_item_id IS NOT NULL;
INSERT INTO permissions (code, description, category) VALUES
('migration_center.view', 'View migration centre', 'migration_center'),
('migration_center.sessions', 'Create and manage migration sessions', 'migration_center'),
('migration_center.import', 'Import CRM subscription snapshots', 'migration_center'),
('migration_center.create', 'Create customers, cases and subscriptions from migration centre', 'migration_center'),
('migration_center.review', 'Link, verify and ignore migration records', 'migration_center'),
('migration_center.lock', 'Lock migration records', 'migration_center'),
('migration_center.retry', 'Retry failed external locks', 'migration_center'),
('migration_center.export', 'Export migration control reports', 'migration_center')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
JOIN permissions p ON p.category = 'migration_center'
WHERE g.name = 'Administrators'
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,11 @@
-- Speed up the subscription-like invoice line classifier and company filters.
CREATE INDEX IF NOT EXISTS idx_mc_items_subscription_visibility
ON migration_center_session_items
(session_id, source_system, entity_type, customer_no, product_code, invoice_date);
CREATE INDEX IF NOT EXISTS idx_mc_items_company_name
ON migration_center_session_items (session_id, LOWER(customer_name));
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_customer_price
ON sag_subscriptions (customer_id, price)
WHERE status <> 'cancelled';

View File

@ -0,0 +1,6 @@
ALTER TABLE migration_center_session_items
ADD COLUMN IF NOT EXISTS subscription_like BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS subscription_relevance_reason VARCHAR(40);
CREATE INDEX IF NOT EXISTS idx_mc_items_visible
ON migration_center_session_items (session_id, subscription_like, entity_type, invoice_date DESC);

Some files were not shown because too many files have changed in this diff Show More