Compare commits
10 Commits
be68148448
...
761753e577
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761753e577 | ||
|
|
0920f2ce30 | ||
|
|
97218d61a8 | ||
|
|
cabe0395ed | ||
|
|
9ac5b91f6f | ||
|
|
88f7cec478 | ||
|
|
533a652337 | ||
|
|
56cc1bdcc4 | ||
|
|
d81a8f41b4 | ||
|
|
428ad21132 |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
64
MDfile/RELEASE_NOTES_v2.5.0.md
Normal file
64
MDfile/RELEASE_NOTES_v2.5.0.md
Normal 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`.
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
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', ''), str(payload))
|
||||
(job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False))
|
||||
)
|
||||
return True, "Mattermost notification sent"
|
||||
else:
|
||||
error_text = await response.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
|
||||
|
||||
@ -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,
|
||||
@ -4813,6 +5089,17 @@ async def reprocess_uploaded_file(file_id: int):
|
||||
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
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,11 +847,21 @@ 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', () => {
|
||||
loadCompanies();
|
||||
@ -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>'
|
||||
: 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.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] : '';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -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
@ -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')));
|
||||
|
||||
@ -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')}`
|
||||
|
||||
@ -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,7 +2415,24 @@ async def upload_emails(files: List[UploadFile] = File(...)):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
|
||||
|
||||
# Execute workflows
|
||||
# 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)
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,21 +759,32 @@
|
||||
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 = [];
|
||||
renderFilters();
|
||||
@ -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>
|
||||
${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 () => {
|
||||
|
||||
@ -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"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -2036,6 +2036,43 @@ 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(
|
||||
"""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),
|
||||
)
|
||||
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 (
|
||||
@ -2060,14 +2097,14 @@ class AlsoService:
|
||||
1,
|
||||
approved_by_user_id,
|
||||
_json_dumps({"source": "also_cloud_billing"}),
|
||||
f"also-cloud-{customer_id}-{period_key}",
|
||||
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")
|
||||
|
||||
if draft_id not in draft_ids:
|
||||
draft_ids.append(draft_id)
|
||||
|
||||
line_id_values = [int(line["id"]) for line in group_lines]
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
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}
|
||||
RETURNING COUNT(*) OVER() AS affected_count
|
||||
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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 "]
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@ -1136,7 +1136,7 @@
|
||||
const payload = getFormPayload();
|
||||
|
||||
if (!payload.name) {
|
||||
alert('Navn er paakraevet.');
|
||||
alert('Navn er påkrævet.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -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')),
|
||||
|
||||
@ -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(
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
41
app/modules/locations/templates/cross_fields.html
Normal file
41
app/modules/locations/templates/cross_fields.html
Normal 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 %}
|
||||
@ -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;
|
||||
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());
|
||||
|
||||
@ -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>
|
||||
|
||||
1
app/modules/migration_center/__init__.py
Normal file
1
app/modules/migration_center/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Manual migration centre for CRM subscriptions and imported invoice lines."""
|
||||
1
app/modules/migration_center/backend/__init__.py
Normal file
1
app/modules/migration_center/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Migration centre backend."""
|
||||
1415
app/modules/migration_center/backend/router.py
Normal file
1415
app/modules/migration_center/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
641
app/modules/migration_center/backend/service.py
Normal file
641
app/modules/migration_center/backend/service.py
Normal 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()
|
||||
1
app/modules/migration_center/frontend/__init__.py
Normal file
1
app/modules/migration_center/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Migration centre frontend."""
|
||||
20
app/modules/migration_center/frontend/views.py
Normal file
20
app/modules/migration_center/frontend/views.py
Normal 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},
|
||||
)
|
||||
11
app/modules/migration_center/module.json
Normal file
11
app/modules/migration_center/module.json
Normal 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"]
|
||||
}
|
||||
349
app/modules/migration_center/templates/index.html
Normal file
349
app/modules/migration_center/templates/index.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@ -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))
|
||||
|
||||
@ -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,))
|
||||
@ -1887,6 +1950,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)
|
||||
VALUES (%s, %s, %s)
|
||||
@ -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"):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]
|
||||
|
||||
418
app/modules/sag/scripts/sag_module_e2e.py
Normal file
418
app/modules/sag/scripts/sag_module_e2e.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
];
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -184,7 +184,8 @@ class EmailWorkflowService:
|
||||
query = f"""
|
||||
SELECT id, name, cvr_number
|
||||
FROM customers
|
||||
WHERE cvr_number IN ({format_strings})
|
||||
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]}")
|
||||
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)
|
||||
|
||||
# Link to the first customer found (limitation of 1:1 schema)
|
||||
first_match = matching_customers[0]
|
||||
|
||||
execute_update(
|
||||
"""UPDATE email_messages
|
||||
SET customer_id = %s, status = 'processed', folder = 'Processed',
|
||||
"""
|
||||
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'], email_data['id'])
|
||||
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]:
|
||||
|
||||
@ -25,9 +25,39 @@ 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,
|
||||
reminder_id: int,
|
||||
@ -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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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(
|
||||
@ -222,13 +282,18 @@ class ReminderNotificationService:
|
||||
"""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)
|
||||
if mm:
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 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>
|
||||
<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 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="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 {
|
||||
|
||||
@ -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 || '-';
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,))
|
||||
|
||||
@ -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.
|
||||
@ -1841,6 +1913,18 @@ async def list_time_entries(
|
||||
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 (
|
||||
t.description ILIKE %s OR
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,8 +118,31 @@
|
||||
<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">
|
||||
@ -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');
|
||||
|
||||
@ -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
106
app/utils/safe_html.py
Normal 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)
|
||||
119
app/vendors/backend/router.py
vendored
119
app/vendors/backend/router.py
vendored
@ -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"""
|
||||
|
||||
85
app/vendors/frontend/vendor_detail.html
vendored
85
app/vendors/frontend/vendor_detail.html
vendored
@ -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;
|
||||
}
|
||||
|
||||
4
main.py
4
main.py
@ -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
|
||||
|
||||
7
migrations/1008_wall_outlet_wan.sql
Normal file
7
migrations/1008_wall_outlet_wan.sql
Normal 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;
|
||||
31
migrations/1009_internet_invoice_sync_runs.sql
Normal file
31
migrations/1009_internet_invoice_sync_runs.sql
Normal 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);
|
||||
15
migrations/1010_internet_invoice_review_decisions.sql
Normal file
15
migrations/1010_internet_invoice_review_decisions.sql
Normal 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);
|
||||
6
migrations/1011_mattermost_notification_settings.sql
Normal file
6
migrations/1011_mattermost_notification_settings.sql
Normal 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;
|
||||
2
migrations/1012_user_mattermost_username.sql
Normal file
2
migrations/1012_user_mattermost_username.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_notification_preferences
|
||||
ADD COLUMN IF NOT EXISTS mattermost_username VARCHAR(100);
|
||||
24
migrations/1013_sag_permissions.sql
Normal file
24
migrations/1013_sag_permissions.sql
Normal 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;
|
||||
26
migrations/1014_vendor_email_domains.sql
Normal file
26
migrations/1014_vendor_email_domains.sql
Normal 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.';
|
||||
9
migrations/1015_add_accounting_group.sql
Normal file
9
migrations/1015_add_accounting_group.sql
Normal 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')
|
||||
);
|
||||
14
migrations/1016_contact_merge_history.sql
Normal file
14
migrations/1016_contact_merge_history.sql
Normal 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.';
|
||||
185
migrations/228_migration_center.sql
Normal file
185
migrations/228_migration_center.sql
Normal 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;
|
||||
@ -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';
|
||||
@ -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
Loading…
Reference in New Issue
Block a user