release: v2.7.2

This commit is contained in:
Christian 2026-08-28 20:49:55 +02:00
parent 2c2db54d16
commit ffdc9ac62c
45 changed files with 5150 additions and 303 deletions

View File

@ -10,6 +10,12 @@ POSTGRES_PASSWORD=bmc_hub
POSTGRES_DB=bmc_hub
POSTGRES_PORT=5433 # Changed from 5432 to avoid conflicts with other services
# Public website content administration API
WEBSITE_CONTENT_API_URL=https://isp.bmcnetworks.dk/api/admin-content.php
WEBSITE_CONTENT_API_HOST_HEADER=ct.bmcnetworks.dk
WEBSITE_CONTENT_API_TOKEN=
WEBSITE_CONTENT_API_TIMEOUT_SECONDS=20
# =====================================================
# API CONFIGURATION
# =====================================================

View File

@ -0,0 +1,30 @@
# BMC Hub v2.7.2
## CRM
- Kunde- og kontaktkort har nye faner til direkte relaterede sager og e-mails.
- Kontaktsøgning understøtter navn i flere rækkefølger, firma, titel, afdeling, e-mail og normaliserede telefonnumre.
- Kontakter kan oprettes eller opdateres fra Outlook `.msg`/`.eml` med godkendelse før lagring.
- CVR fra mails valideres gennem den eksisterende FirmaAPI-integration, og eksisterende virksomheder genbruges.
- Leverandørservice kan administreres direkte fra kundens økonomiske oplysninger.
## Sager og telefoni
- Nye sager forvælger den aktuelle bruger som ansvarlig.
- Kontaktkort på sager har direkte handlinger til opkald og SMS.
- Opkald startet fra en sag knyttes til sagen og vises i opkaldshistorikken.
- Firmaet i sagens topområde linker direkte til kundekortet.
## Abonnementer
- Udvidet aftale-, faktureringskalender- og ændringsflow.
- Nye migreringer til første fakturalinjer, periodisering og integritetskontrol.
## Websiteindhold
- Nyt administrationsmodul til kundereferencer og driftsinformation.
- Rettighedsstyret visning og redigering samt website-endpoints til indhold og logoer.
## Verifikation
- Målrettede CRM-, sag-, telefoni-, abonnements- og website-tests er kørt før release.

View File

@ -1 +1 @@
2.7.1
2.7.2

View File

@ -3,8 +3,8 @@ Contact API Router - Simplified (Read-Only)
Only GET endpoints for now
"""
from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File
from typing import Any, Optional
from pydantic import BaseModel, Field
from app.core.database import (
execute_query,
@ -15,6 +15,7 @@ from app.core.database import (
)
from psycopg2.extras import RealDictCursor
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.services.cvr_service import get_cvr_service
from app.customers.backend.router import (
get_customer_subscriptions,
lock_customer_subscriptions,
@ -22,9 +23,14 @@ from app.customers.backend.router import (
get_subscription_comment,
get_subscription_billing_matrix,
SubscriptionComment,
CustomerCreate,
create_customer,
)
import logging
import json
import re
import html
from email.utils import parseaddr
logger = logging.getLogger(__name__)
router = APIRouter()
@ -63,6 +69,17 @@ class ContactMergeRequest(BaseModel):
source_contact_id: int = Field(..., gt=0)
class EmailCompanyResolve(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
cvr_number: str = Field(..., pattern=r"^\d{8}$")
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
postal_code: Optional[str] = None
website: Optional[str] = None
CONTACT_MERGE_RELATIONS = (
("Firmaer", "contact_companies", "contact_id"),
("Sager", "sag_kontakter", "contact_id"),
@ -79,6 +96,325 @@ CONTACT_MERGE_RELATIONS = (
)
def _exact_email_pattern(address: str) -> str:
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
def _plain_email_body(parsed: dict) -> str:
text = str(parsed.get("body_text") or "").strip()
if text:
return html.unescape(text).replace("\xa0", " ")
raw_html = str(parsed.get("body_html") or "")
raw_html = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", raw_html)
raw_html = re.sub(r"(?i)<br\s*/?>|</p>|</div>", "\n", raw_html)
return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw_html)).replace("\xa0", " ")
def _signature_lines(body: str) -> list[str]:
"""Normalize common Outlook/Markdown decoration without losing line structure."""
lines = []
for raw_line in body.splitlines():
line = re.sub(r"[*_`]+", "", raw_line)
line = re.sub(r"\s+", " ", line).strip(" \\|")
if line:
lines.append(line)
return lines
def _labelled_phone(lines: list[str], labels: str) -> Optional[str]:
phone_pattern = re.compile(r"\+?\d(?:[\s().-]*\d){5,14}")
label_pattern = re.compile(rf"(?i)^\s*(?:{labels})\s*:\s*(.*)$")
for line in lines:
labelled = label_pattern.search(line)
if labelled:
phone = phone_pattern.search(labelled.group(1))
if phone:
return phone.group(0).strip()
return None
def _contact_suggestions_from_email(contact: dict, parsed: dict) -> list[dict]:
"""Conservative, review-only extraction from headers and labelled signature lines."""
body = _plain_email_body(parsed)
signature_lines = _signature_lines(body)
sender_email = str(parsed.get("sender_email") or "").strip().lower()
sender_name = str(parsed.get("sender_name") or "").strip()
parsed_sender_name, parsed_sender_address = parseaddr(sender_name)
if parsed_sender_name:
sender_name = parsed_sender_name
if not sender_email and parsed_sender_address:
sender_email = parsed_sender_address.strip().lower()
current_email = str(contact.get("email") or "").strip().lower()
candidates: dict[str, tuple[str, str]] = {}
# A received Outlook message normally represents the sender. If this looks
# like a sent message to the current contact, do not suggest our own sender.
recipient_text = str(parsed.get("recipient_email") or "").lower()
sender_represents_contact = not current_email or current_email == sender_email or current_email not in recipient_text
if sender_represents_contact and sender_email:
candidates["email"] = (sender_email, "Mailens afsender")
if sender_represents_contact and sender_name and "@" not in sender_name:
name_parts = sender_name.split()
if name_parts:
candidates["first_name"] = (name_parts[0], "Afsendernavn")
if len(name_parts) > 1:
candidates["last_name"] = (" ".join(name_parts[1:]), "Afsendernavn")
mobile = _labelled_phone(signature_lines, r"mobil|mobile|mob\.?")
phone = _labelled_phone(signature_lines, r"telefon|phone|tel\.?|direkte")
if mobile:
candidates["mobile"] = (mobile, "Mailens signatur")
if phone:
candidates["phone"] = (phone, "Mailens signatur")
for field, labels_pattern in (("title", r"titel|stilling|job title"), ("department", r"afdeling|department")):
pattern = re.compile(rf"(?i)^\s*(?:{labels_pattern})\s*:\s*(.{{2,100}}?)\s*$")
for line in signature_lines:
match = pattern.search(line)
if match:
candidates[field] = (match.group(1).strip(), "Mailens signatur")
break
# Outlook signatures often put an unlabelled job title directly below the
# sender's name. This remains a review-only suggestion.
contact_full_name = " ".join(
part for part in (str(contact.get("first_name") or "").strip(), str(contact.get("last_name") or "").strip()) if part
)
signature_names = [name.casefold() for name in (contact_full_name, sender_name) if len(name.split()) >= 2]
rejected_titles = {
"kind regards", "best regards", "regards", "med venlig hilsen",
"venlig hilsen", "mvh", "thanks", "thank you", "tak",
}
for signature_name in dict.fromkeys(signature_names):
for index in range(len(signature_lines) - 2, -1, -1):
if signature_lines[index].casefold() != signature_name:
continue
possible_title = signature_lines[index + 1]
if (
"title" not in candidates
and 2 <= len(possible_title) <= 100
and possible_title.casefold().rstrip(",.! ") not in rejected_titles
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|e-mail|web|www)\s*:", possible_title)
and not re.search(r"[@\d]|https?://", possible_title)
):
candidates["title"] = (possible_title, "Linjen under afsendernavnet")
break
if "title" in candidates:
break
labels = {
"first_name": "Fornavn", "last_name": "Efternavn", "email": "E-mail",
"phone": "Telefon", "mobile": "Mobil", "title": "Titel", "department": "Afdeling",
}
suggestions = []
for field, (value, source) in candidates.items():
current = str(contact.get(field) or "").strip()
if value and current.casefold() != value.casefold():
suggestions.append({"field": field, "label": labels[field], "current": current or None, "suggested": value, "source": source})
return suggestions
def _company_from_email_body(body: str, contact_name: str = "") -> dict:
lines = _signature_lines(body)
joined = "\n".join(lines)
cvr_match = re.search(r"(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d[\d .-]{6,12}\d)", joined)
cvr_number = re.sub(r"\D", "", cvr_match.group(1)) if cvr_match else ""
if len(cvr_number) != 8:
cvr_number = ""
company_name = ""
labelled_name = re.search(r"(?im)^\s*(?:firma|company|virksomhed)\s*:\s*(.{2,100})$", joined)
if labelled_name:
company_name = labelled_name.group(1).strip()
if not company_name:
for index, line in enumerate(lines):
if not (re.search(r"\b\d{4}\s+[A-Za-zÆØÅæøå]", line) or re.search(r"[A-Za-zÆØÅæøå]{3,}\s+\d+[A-Za-z]?\b", line)):
continue
if index:
candidate = lines[index - 1].strip()
if (
candidate.casefold() != contact_name.casefold()
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|web|cvr)\s*:", candidate)
and not re.search(r"[@+]|https?://", candidate)
):
company_name = candidate
break
return {"name": company_name or None, "cvr_number": cvr_number or None}
@router.post("/contacts/analyze-email")
async def analyze_email_for_new_contact(file: UploadFile = File(...)):
"""Return editable contact/company candidates without creating anything."""
filename = str(file.filename or "").lower()
if not filename.endswith((".eml", ".msg")):
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
content = await file.read()
if not content or len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
from app.services.email_service import EmailService
parser = EmailService()
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
if not parsed:
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")}
suggestions = _contact_suggestions_from_email(empty_contact, parsed)
contact = {item["field"]: item["suggested"] for item in suggestions}
contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
company = _company_from_email_body(_plain_email_body(parsed), contact_name)
company["existing_id"] = None
company["existing_name"] = None
company["lookup_found"] = False
if company.get("cvr_number"):
existing = execute_query_single(
"""SELECT id, name FROM customers
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
ORDER BY id LIMIT 1""",
(company["cvr_number"],),
)
if existing:
company["existing_id"] = existing.get("id")
company["existing_name"] = existing.get("name")
official = await get_cvr_service().lookup_by_cvr(company["cvr_number"])
if official:
company.update({
"name": official.get("name") or company.get("name"),
"address": official.get("address"),
"city": official.get("city"),
"postal_code": official.get("postal_code") or official.get("zipcode"),
"phone": official.get("phone"),
"email": official.get("email"),
"website": official.get("website"),
"status": official.get("status"),
"source": official.get("source"),
"lookup_found": True,
})
return {
"filename": file.filename,
"mail": {"subject": parsed.get("subject"), "sender_email": parsed.get("sender_email")},
"contact": contact,
"company": company,
}
@router.post("/contacts/resolve-email-company")
async def resolve_email_company(payload: EmailCompanyResolve):
"""Reuse a CVR match or create the approved company exactly once."""
existing = execute_query_single(
"""SELECT id, name, cvr_number FROM customers
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
ORDER BY id LIMIT 1""",
(payload.cvr_number,),
)
if existing:
return {**existing, "created": False}
created = await create_customer(CustomerCreate(
name=payload.name,
cvr_number=payload.cvr_number,
email=payload.email,
phone=payload.phone,
address=payload.address,
city=payload.city,
postal_code=payload.postal_code,
website=payload.website,
is_active=True,
))
return {**created, "created": True}
@router.post("/contacts/{contact_id}/analyze-email")
async def analyze_contact_email(contact_id: int, file: UploadFile = File(...)):
"""Parse an Outlook email and return reviewable contact update suggestions."""
contact = execute_query_single(
"SELECT id, first_name, last_name, email, phone, mobile, title, department FROM contacts WHERE id = %s",
(contact_id,),
)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
filename = str(file.filename or "").lower()
if not filename.endswith((".eml", ".msg")):
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
content = await file.read()
if not content or len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
from app.services.email_service import EmailService
parser = EmailService()
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
if not parsed:
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
suggestions = _contact_suggestions_from_email(contact, parsed)
return {
"filename": file.filename,
"mail": {"subject": parsed.get("subject"), "sender_name": parsed.get("sender_name"), "sender_email": parsed.get("sender_email")},
"suggestions": suggestions,
}
@router.get("/contacts/{contact_id}/emails")
async def get_contact_emails(
contact_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
contact = execute_query_single("SELECT id, email FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
address = str(contact.get("email") or "").strip().lower()
if not address:
return {"items": [], "total": 0, "limit": limit, "offset": offset}
predicate = "em.deleted_at IS NULL AND LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc)) ~ %s"
params = (_exact_email_pattern(address),)
total = execute_query_single(f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", params)
rows = execute_query(
f"""
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
em.linked_case_id, s.titel AS linked_case_title
FROM email_messages em
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
WHERE {predicate}
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
LIMIT %s OFFSET %s
""",
(*params, limit, offset),
) or []
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
@router.get("/contacts/{contact_id}/cases")
async def get_contact_cases(
contact_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
if not execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,)):
raise HTTPException(status_code=404, detail="Contact not found")
predicate = "sk.contact_id = %s AND sk.deleted_at IS NULL AND s.deleted_at IS NULL"
total = execute_query_single(
f"SELECT COUNT(*) AS count FROM sag_kontakter sk JOIN sag_sager s ON s.id = sk.sag_id WHERE {predicate}",
(contact_id,),
)
rows = execute_query(
f"""
SELECT s.id, s.titel, s.status, s.updated_at, s.customer_id,
cu.name AS customer_name,
s.ansvarlig_bruger_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name
FROM sag_kontakter sk
JOIN sag_sager s ON s.id = sk.sag_id
LEFT JOIN customers cu ON cu.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE {predicate}
ORDER BY s.updated_at DESC NULLS LAST, s.id DESC
LIMIT %s OFFSET %s
""",
(contact_id, limit, offset),
) or []
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
class ContactCompanyLink(BaseModel):
customer_id: int
is_primary: bool = True
@ -147,28 +483,32 @@ async def get_contacts(
where_clauses = []
params = []
if search:
where_clauses.append(
"""
normalized_search = " ".join(str(search or "").split())
if normalized_search:
for token in normalized_search.split(" "):
token_clause = """
(
c.first_name ILIKE %s
OR c.last_name ILIKE %s
OR c.email ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR c.user_company ILIKE %s
CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s
OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s
OR COALESCE(c.email, '') ILIKE %s
OR COALESCE(c.title, '') ILIKE %s
OR COALESCE(c.department, '') ILIKE %s
OR COALESCE(c.user_company, '') ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc2
SELECT 1 FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s
)
"""
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like, like])
token_params: list[Any] = [f"%{token}%"] * 7
digits = "".join(ch for ch in token if ch.isdigit())
if len(digits) >= 2:
token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s"
token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s"
token_params.extend([f"%{digits}%", f"%{digits}%"])
token_clause += ")"
where_clauses.append(token_clause)
params.extend(token_params)
if is_active is not None:
where_clauses.append("c.is_active = %s")
@ -188,16 +528,33 @@ async def get_contacts(
total = count_result[0]['count'] if count_result else 0
# Step 1: Fetch contacts only (stable pagination)
rank_order_sql = ""
rank_params: list[Any] = []
if normalized_search:
rank_sql = """
CASE
WHEN LOWER(CONCAT_WS(' ', c.first_name, c.last_name)) = LOWER(%s) THEN 0
WHEN LOWER(CONCAT_WS(' ', c.last_name, c.first_name)) = LOWER(%s) THEN 1
WHEN CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s THEN 2
WHEN CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s THEN 3
WHEN COALESCE(c.email, '') ILIKE %s THEN 4
ELSE 5
END
"""
rank_order_sql = f"{rank_sql},"
rank_params = [normalized_search, normalized_search, f"{normalized_search}%", f"{normalized_search}%", f"{normalized_search}%"]
contacts_query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
FROM contacts c
{where_sql}
ORDER BY c.last_name, c.first_name, c.id
ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s
"""
contacts_params = list(params)
contacts_params.extend(rank_params)
contacts_params.extend([limit, offset])
contacts = execute_query(contacts_query, tuple(contacts_params)) or []

View File

@ -116,6 +116,21 @@
top: 1rem;
right: 1rem;
}
.contact-email-dropzone {
border: 1px dashed rgba(15, 76, 117, 0.45);
border-radius: 12px;
background: rgba(15, 76, 117, 0.04);
padding: 0.8rem 1rem;
cursor: pointer;
transition: background-color .2s, border-color .2s, transform .2s;
}
.contact-email-dropzone.is-dragging {
background: rgba(15, 76, 117, 0.12);
border-color: var(--accent);
transform: translateY(-1px);
}
</style>
{% endblock %}
@ -150,6 +165,18 @@
</div>
</div>
<div id="contactEmailDropzone" class="contact-email-dropzone mb-3" role="button" tabindex="0" aria-label="Analysér Outlook-mail for kontaktopdateringer">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-envelope-arrow-down fs-4 text-primary"></i>
<div class="flex-grow-1">
<div class="fw-semibold">Opdatér kontakt fra Outlook-mail</div>
<div class="small text-muted">Træk en .msg- eller .eml-fil hertil, eller klik for at vælge. Intet ændres uden din godkendelse.</div>
</div>
<div id="contactEmailAnalyzeStatus" class="small text-muted"></div>
</div>
<input id="contactEmailFileInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
</div>
<!-- Alert Notes Container -->
<div id="alert-notes-container"></div>
@ -173,6 +200,16 @@
<i class="bi bi-people"></i>Kontakter
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#cases">
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#emails">
<i class="bi bi-envelope"></i>E-mails
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#invoices">
<i class="bi bi-receipt"></i>Fakturaer
@ -343,6 +380,24 @@
</div>
</div>
<div class="tab-pane fade" id="cases">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktens sager</h5>
<span id="contactCasesCount" class="badge bg-secondary">0</span>
</div>
<div id="contactCasesContainer" class="table-responsive"></div>
<div id="contactCasesPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<div class="tab-pane fade" id="emails">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktens e-mails</h5>
<span id="contactEmailsCount" class="badge bg-secondary">0</span>
</div>
<div id="contactEmailsContainer" class="table-responsive"></div>
<div id="contactEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<!-- Invoices Tab -->
<div class="tab-pane fade" id="invoices">
<h5 class="fw-bold mb-4">Fakturaer</h5>
@ -832,6 +887,27 @@
</div>
</div>
</div>
<div class="modal fade" id="contactEmailSuggestionsModal" 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">Foreslåede kontaktopdateringer</h5>
<div id="contactEmailSuggestionMeta" class="small text-muted"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="contactEmailSuggestionsBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="applyContactEmailSuggestionsBtn" onclick="applyContactEmailSuggestions()">
<i class="bi bi-check-lg me-1"></i>Godkend valgte
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -849,10 +925,12 @@ let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
let pendingContactEmailSuggestions = [];
document.addEventListener('DOMContentLoaded', () => {
loadContact();
loadCompaniesForSelect();
initializeContactEmailDropzone();
const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) {
@ -881,6 +959,9 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
document.querySelector('a[href="#cases"]')?.addEventListener('shown.bs.tab', () => loadContactCases());
document.querySelector('a[href="#emails"]')?.addEventListener('shown.bs.tab', () => loadContactEmails());
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => {
@ -938,6 +1019,150 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
function initializeContactEmailDropzone() {
const zone = document.getElementById('contactEmailDropzone');
const input = document.getElementById('contactEmailFileInput');
if (!zone || !input) return;
const choose = () => input.click();
zone.addEventListener('click', choose);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(); }
});
input.addEventListener('change', () => {
if (input.files?.[0]) analyzeContactEmailFile(input.files[0]);
input.value = '';
});
['dragenter', 'dragover'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
const file = event.dataTransfer?.files?.[0];
if (file) analyzeContactEmailFile(file);
});
}
async function analyzeContactEmailFile(file) {
const status = document.getElementById('contactEmailAnalyzeStatus');
const lower = String(file?.name || '').toLowerCase();
if (!lower.endsWith('.msg') && !lower.endsWith('.eml')) {
status.className = 'small text-danger';
status.textContent = 'Vælg en .msg- eller .eml-fil';
return;
}
status.className = 'small text-muted';
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyserer…';
const form = new FormData();
form.append('file', file);
try {
const response = await fetch(`/api/v1/contacts/${contactId}/analyze-email`, { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
pendingContactEmailSuggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
renderContactEmailSuggestions(data);
bootstrap.Modal.getOrCreateInstance(document.getElementById('contactEmailSuggestionsModal')).show();
status.className = 'small text-success';
status.textContent = `${pendingContactEmailSuggestions.length} forslag fundet`;
} catch (error) {
status.className = 'small text-danger';
status.textContent = error.message;
}
}
function renderContactEmailSuggestions(data) {
const mail = data.mail || {};
document.getElementById('contactEmailSuggestionMeta').textContent = [data.filename, mail.subject, mail.sender_email].filter(Boolean).join(' · ');
const body = document.getElementById('contactEmailSuggestionsBody');
const button = document.getElementById('applyContactEmailSuggestionsBtn');
if (!pendingContactEmailSuggestions.length) {
body.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-check-circle fs-2 d-block mb-2"></i>Ingen nye kontaktoplysninger fundet.</div>';
button.disabled = true;
return;
}
button.disabled = false;
body.innerHTML = `<div class="table-responsive"><table class="table align-middle"><thead><tr><th></th><th>Felt</th><th>Nuværende</th><th>Foreslået</th><th>Kilde</th></tr></thead><tbody>${pendingContactEmailSuggestions.map((item, index) => `
<tr><td><input class="form-check-input contact-email-suggestion-check" type="checkbox" data-index="${index}" checked></td><td class="fw-semibold">${escapeHtml(item.label)}</td><td class="text-muted">${escapeHtml(item.current || '—')}</td><td>${escapeHtml(item.suggested)}</td><td><span class="badge bg-light text-dark border">${escapeHtml(item.source)}</span></td></tr>`).join('')}</tbody></table></div>`;
}
async function applyContactEmailSuggestions() {
const selected = Array.from(document.querySelectorAll('.contact-email-suggestion-check:checked'))
.map(input => pendingContactEmailSuggestions[Number(input.dataset.index)])
.filter(Boolean);
if (!selected.length) return;
const payload = Object.fromEntries(selected.map(item => [item.field, item.suggested]));
const button = document.getElementById('applyContactEmailSuggestionsBtn');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Opdateringen kunne ikke gemmes');
bootstrap.Modal.getInstance(document.getElementById('contactEmailSuggestionsModal'))?.hide();
await loadContact();
const status = document.getElementById('contactEmailAnalyzeStatus');
status.className = 'small text-success';
status.textContent = `${selected.length} felt(er) opdateret`;
} catch (error) {
alert(error.message);
button.disabled = false;
}
}
const contactRelatedPageSize = 25;
let contactCasesOffset = 0;
let contactEmailsOffset = 0;
function contactPagination(hostId, total, offset, loaderName) {
const next = offset + contactRelatedPageSize;
document.getElementById(hostId).innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${offset === 0 ? 'disabled' : ''} onclick="${loaderName}(${Math.max(0, offset - contactRelatedPageSize)})">Forrige</button>
<span class="small text-muted">${total ? offset + 1 : 0}${Math.min(next, total)} af ${total}</span>
<button class="btn btn-sm btn-outline-secondary" ${next >= total ? 'disabled' : ''} onclick="${loaderName}(${next})">Næste</button>`;
}
async function loadContactCases(offset = contactCasesOffset) {
contactCasesOffset = Math.max(0, offset);
const host = document.getElementById('contactCasesContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/cases?limit=${contactRelatedPageSize}&offset=${contactCasesOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente sager');
const data = await response.json();
document.getElementById('contactCasesCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Sag</th><th>Status</th><th>Kunde</th><th>Ansvarlig</th><th>Opdateret</th></tr></thead><tbody>${data.items.map(item => `
<tr role="button" onclick="window.location.href='/sag/${item.id}'"><td><strong>#${item.id}</strong> ${escapeHtml(item.titel || '')}</td><td>${escapeHtml(item.status || '-')}</td><td>${escapeHtml(item.customer_name || '-')}</td><td>${escapeHtml(item.responsible_name || 'Ikke tildelt')}</td><td>${escapeHtml(item.updated_at ? new Date(item.updated_at).toLocaleString('da-DK') : '-')}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen direkte tilknyttede sager</div>';
contactPagination('contactCasesPagination', data.total, contactCasesOffset, 'loadContactCases');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactCasesPagination').innerHTML = '';
}
}
async function loadContactEmails(offset = contactEmailsOffset) {
contactEmailsOffset = Math.max(0, offset);
const host = document.getElementById('contactEmailsContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/emails?limit=${contactRelatedPageSize}&offset=${contactEmailsOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
const data = await response.json();
document.getElementById('contactEmailsCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'"><td>${escapeHtml(email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-')}</td><td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td><td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td><td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td><td>${email.has_attachments ? '<i class="bi bi-paperclip"></i>' : ''}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen e-mails fundet for kontaktens adresse</div>';
contactPagination('contactEmailsPagination', data.total, contactEmailsOffset, 'loadContactEmails');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactEmailsPagination').innerHTML = '';
}
}
async function loadContact() {
try {
const response = await fetch(`/api/v1/contacts/${contactId}`);

View File

@ -490,6 +490,24 @@
padding: 0;
}
.create-mail-dropzone {
min-width: 210px;
border: 1px dashed rgba(15, 76, 117, 0.55);
border-radius: 12px;
background: rgba(15, 76, 117, 0.05);
color: var(--accent);
padding: 0.55rem 0.8rem;
cursor: pointer;
transition: background-color .2s, border-color .2s, transform .2s;
}
.create-mail-dropzone:hover,
.create-mail-dropzone.is-dragging {
background: rgba(15, 76, 117, 0.14);
border-color: var(--accent);
transform: translateY(-1px);
}
@media (max-width: 992px) {
.contacts-toolbar {
width: 100%;
@ -528,10 +546,17 @@
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<div id="createFromEmailDropzone" class="create-mail-dropzone d-flex align-items-center gap-2" role="button" tabindex="0" title="Træk en mail direkte fra Outlook hertil">
<i class="bi bi-envelope-arrow-down fs-5"></i>
<div><div class="fw-semibold small">Træk Outlook-mail hertil</div><div class="text-muted" style="font-size:.7rem">eller klik for at vælge</div></div>
</div>
<input id="createFromEmailInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button>
</div>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
@ -632,6 +657,55 @@
</div>
</div>
<!-- Create contact from Outlook email -->
<div class="modal fade" id="createFromEmailModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Opret kontakt ud fra mail</h5>
<div id="createFromEmailMeta" class="small text-muted"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="createFromEmailStatus" class="alert alert-info d-none"></div>
<h6 class="fw-bold">Kontaktoplysninger</h6>
<div class="row g-3 mb-4">
<div class="col-md-6"><label class="form-label">Fornavn</label><input id="mailContactFirstName" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Efternavn</label><input id="mailContactLastName" class="form-control"></div>
<div class="col-md-6"><label class="form-label">E-mail</label><input id="mailContactEmail" type="email" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Mobil</label><input id="mailContactMobile" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Telefon</label><input id="mailContactPhone" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Titel</label><input id="mailContactTitle" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Afdeling</label><input id="mailContactDepartment" class="form-control"></div>
</div>
<div id="mailCompanySection" class="border rounded-3 p-3 d-none">
<div class="form-check mb-3">
<input id="mailUseCompany" class="form-check-input" type="checkbox" checked>
<label id="mailUseCompanyLabel" class="form-check-label fw-semibold" for="mailUseCompany">Tilknyt virksomhed</label>
</div>
<div class="row g-3">
<div class="col-md-7"><label class="form-label">Virksomhedsnavn</label><input id="mailCompanyName" class="form-control"></div>
<div class="col-md-5"><label class="form-label">CVR</label><input id="mailCompanyCvr" class="form-control" maxlength="8"></div>
<div class="col-md-7"><label class="form-label">Adresse</label><input id="mailCompanyAddress" class="form-control"></div>
<div class="col-md-2"><label class="form-label">Postnr.</label><input id="mailCompanyPostalCode" class="form-control"></div>
<div class="col-md-3"><label class="form-label">By</label><input id="mailCompanyCity" class="form-control"></div>
</div>
<div id="mailCompanyHint" class="form-text mt-2"></div>
</div>
<div id="mailNoCompany" class="text-muted small border rounded-3 p-3">Intet CVR fundet. Der oprettes ikke en virksomhed automatisk.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button id="approveCreateFromEmailBtn" type="button" class="btn btn-primary" onclick="approveCreateFromEmail()">
<i class="bi bi-check-lg me-1"></i>Godkend og opret
</button>
</div>
</div>
</div>
</div>
<!-- Create Contact Modal -->
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@ -819,6 +893,7 @@ let selectedCompanyIds = new Set();
let currentContactsData = [];
let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null;
let analyzedMailCompany = null;
let currentSort = {
key: 'name',
direction: 'asc'
@ -832,6 +907,12 @@ let visibleColumns = {
// Load contacts on page load
document.addEventListener('DOMContentLoaded', () => {
initializeCreateFromEmailDropzone();
document.getElementById('createFromEmailInput')?.addEventListener('change', event => {
const file = event.target.files?.[0];
if (file) analyzeEmailForNewContact(file);
event.target.value = '';
});
const urlParams = new URLSearchParams(window.location.search);
const preselectedCustomerId = Number(urlParams.get('customer_id'));
const shouldOpenCreateModal = urlParams.get('create') === '1';
@ -918,6 +999,42 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
function initializeCreateFromEmailDropzone() {
const zone = document.getElementById('createFromEmailDropzone');
const input = document.getElementById('createFromEmailInput');
if (!zone || !input) return;
const chooseFile = () => input.click();
zone.addEventListener('click', chooseFile);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
chooseFile();
}
});
['dragenter', 'dragover'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
zone.classList.add('is-dragging');
}));
['dragleave', 'dragend'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragging');
const file = event.dataTransfer?.files?.[0]
|| Array.from(event.dataTransfer?.items || []).find(item => item.kind === 'file')?.getAsFile();
if (!file) {
alert('Outlook afleverede ikke mailen som en fil. Prøv at gemme eller trække den som .msg/.eml, eller klik på boksen.');
return;
}
analyzeEmailForNewContact(file);
});
}
function setFilter(filter) {
currentFilter = filter;
currentPage = 0;
@ -1425,6 +1542,105 @@ function renderSelectedCompanies() {
`).join('');
}
async function analyzeEmailForNewContact(file) {
const form = new FormData();
form.append('file', file);
const zone = document.getElementById('createFromEmailDropzone');
const original = zone?.innerHTML;
if (zone) { zone.style.pointerEvents = 'none'; zone.innerHTML = '<span class="spinner-border spinner-border-sm"></span><span class="small fw-semibold">Analyserer…</span>'; }
try {
const response = await fetch('/api/v1/contacts/analyze-email', { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
const contact = data.contact || {};
const values = {
mailContactFirstName: contact.first_name,
mailContactLastName: contact.last_name,
mailContactEmail: contact.email,
mailContactMobile: contact.mobile,
mailContactPhone: contact.phone,
mailContactTitle: contact.title,
mailContactDepartment: contact.department,
mailCompanyName: data.company?.existing_name || data.company?.name,
mailCompanyCvr: data.company?.cvr_number,
mailCompanyAddress: data.company?.address,
mailCompanyPostalCode: data.company?.postal_code,
mailCompanyCity: data.company?.city,
};
Object.entries(values).forEach(([id, value]) => { document.getElementById(id).value = value || ''; });
analyzedMailCompany = data.company || {};
document.getElementById('createFromEmailMeta').textContent = [data.filename, data.mail?.subject, data.mail?.sender_email].filter(Boolean).join(' · ');
const hasCvr = !!data.company?.cvr_number;
document.getElementById('mailCompanySection').classList.toggle('d-none', !hasCvr);
document.getElementById('mailNoCompany').classList.toggle('d-none', hasCvr);
if (hasCvr) {
const existing = !!data.company.existing_id;
document.getElementById('mailUseCompanyLabel').textContent = existing ? 'Tilknyt eksisterende virksomhed' : 'Opret og tilknyt ny virksomhed';
document.getElementById('mailCompanyHint').textContent = existing
? `CVR findes allerede som ${data.company.existing_name}. Der oprettes ikke en dublet.`
: data.company.lookup_found
? `Virksomhedsdata er hentet fra CVR-opslaget (${data.company.source || 'CVR-registeret'}). Kontrollér oplysningerne før oprettelse.`
: 'CVR findes ikke i systemet, men kunne ikke valideres i CVR-opslaget. Kontrollér oplysningerne før oprettelse.';
document.getElementById('mailCompanyName').readOnly = existing;
document.getElementById('mailCompanyCvr').readOnly = existing;
document.getElementById('mailUseCompany').checked = true;
}
document.getElementById('createFromEmailStatus').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('createFromEmailModal')).show();
} catch (error) {
alert(error.message);
} finally {
if (zone) { zone.style.pointerEvents = ''; zone.innerHTML = original; }
}
}
async function approveCreateFromEmail() {
const value = id => document.getElementById(id).value.trim() || null;
const contactData = {
first_name: value('mailContactFirstName'), last_name: value('mailContactLastName') || '',
email: value('mailContactEmail'), mobile: value('mailContactMobile'), phone: value('mailContactPhone'),
title: value('mailContactTitle'), department: value('mailContactDepartment'), is_active: true,
};
if (!contactData.first_name) { alert('Fornavn skal udfyldes'); return; }
const button = document.getElementById('approveCreateFromEmailBtn');
const status = document.getElementById('createFromEmailStatus');
button.disabled = true;
status.className = 'alert alert-info';
status.textContent = 'Opretter…';
try {
let companyId = document.getElementById('mailUseCompany').checked ? analyzedMailCompany?.existing_id : null;
if (document.getElementById('mailUseCompany').checked && analyzedMailCompany?.cvr_number && !companyId) {
const companyName = value('mailCompanyName');
const cvrNumber = (value('mailCompanyCvr') || '').replace(/\D/g, '');
if (!companyName || cvrNumber.length !== 8) throw new Error('Virksomhedsnavn og et gyldigt CVR på 8 cifre skal udfyldes');
const companyResponse = await fetch('/api/v1/contacts/resolve-email-company', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: companyName, cvr_number: cvrNumber,
email: analyzedMailCompany?.email || contactData.email,
phone: analyzedMailCompany?.phone || null,
address: value('mailCompanyAddress'), postal_code: value('mailCompanyPostalCode'),
city: value('mailCompanyCity'), website: analyzedMailCompany?.website || null
})
});
const company = await companyResponse.json().catch(() => ({}));
if (!companyResponse.ok) throw new Error(company.detail || 'Virksomheden kunne ikke oprettes');
companyId = company.id;
}
if (companyId) { contactData.company_ids = [Number(companyId)]; contactData.is_primary = true; }
const response = await fetch('/api/v1/contacts', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contactData)
});
const contact = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes');
window.location.href = `/contacts/${contact.id}`;
} catch (error) {
status.className = 'alert alert-danger';
status.textContent = error.message;
button.disabled = false;
}
}
function showCreateContactModal() {
// Reset form
document.getElementById('createContactForm').reset();

View File

@ -15,6 +15,21 @@ class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql://bmc_hub:bmc_hub@localhost:5432/bmc_hub"
# Public website content administration API (HTTPS on Plesk)
WEBSITE_CONTENT_API_URL: str = "https://isp.bmcnetworks.dk/api/admin-content.php"
WEBSITE_CONTENT_API_HOST_HEADER: str = "ct.bmcnetworks.dk"
WEBSITE_CONTENT_API_TOKEN: str = ""
WEBSITE_CONTENT_API_TIMEOUT_SECONDS: int = 20
# Deprecated direct-MySQL settings retained temporarily for .env compatibility.
WEBSITE_CONTENT_MYSQL_HOST: str = ""
WEBSITE_CONTENT_MYSQL_PORT: int = 3306
WEBSITE_CONTENT_MYSQL_DATABASE: str = ""
WEBSITE_CONTENT_MYSQL_USER: str = ""
WEBSITE_CONTENT_MYSQL_PASSWORD: str = ""
WEBSITE_CONTENT_MYSQL_POOL_SIZE: int = 5
WEBSITE_CONTENT_MYSQL_CONNECT_TIMEOUT: int = 8
WEBSITE_CONTENT_LOGO_BASE_URL: str = ""
# API
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000

View File

@ -10,6 +10,7 @@ from pydantic import BaseModel
import logging
import asyncio
import aiohttp
import re
from urllib.parse import quote
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
@ -111,6 +112,7 @@ class CustomerBase(BaseModel):
is_active: Optional[bool] = True
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
supplier_service_enrolled: Optional[bool] = False
class CustomerCreate(CustomerBase):
@ -133,6 +135,7 @@ class CustomerUpdate(BaseModel):
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
department: Optional[str] = None
supplier_service_enrolled: Optional[bool] = None
class EmailDomainCreate(BaseModel):
@ -580,6 +583,76 @@ async def get_customer(customer_id: int):
}
def _email_address_pattern(address: str) -> str:
"""PostgreSQL regex matching one exact address inside common address lists."""
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
def _email_domain_pattern(domain: str) -> str:
"""PostgreSQL regex matching an address at exactly this domain."""
return rf"(^|[^a-z0-9._%+-])[a-z0-9._%+-]+@{re.escape(domain.lower())}([^a-z0-9.-]|$)"
@router.get("/customers/{customer_id}/emails")
async def get_customer_emails(
customer_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
"""List emails safely associated with a customer without mutating links."""
customer = execute_query_single("SELECT id, email FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
contact_rows = execute_query(
"""
SELECT DISTINCT LOWER(TRIM(c.email)) AS email
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s AND NULLIF(TRIM(c.email), '') IS NOT NULL
""",
(customer_id,),
) or []
domain_rows = execute_query(
"SELECT LOWER(TRIM(domain)) AS domain FROM email_domain_customer_mappings WHERE customer_id = %s",
(customer_id,),
) or []
addresses = {str(row["email"]).lower() for row in contact_rows if row.get("email")}
if customer.get("email"):
addresses.add(str(customer["email"]).strip().lower())
domains = {str(row["domain"]).lower() for row in domain_rows if row.get("domain")}
haystack = "LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc))"
match_clauses = ["em.customer_id = %s"]
match_params: list[Any] = [customer_id]
for address in sorted(addresses):
match_clauses.append(f"{haystack} ~ %s")
match_params.append(_email_address_pattern(address))
for domain in sorted(domains):
match_clauses.append(f"{haystack} ~ %s")
match_params.append(_email_domain_pattern(domain))
predicate = f"em.deleted_at IS NULL AND ({' OR '.join(match_clauses)})"
total_row = execute_query_single(
f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", tuple(match_params)
)
rows = execute_query(
f"""
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
em.linked_case_id, s.titel AS linked_case_title
FROM email_messages em
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
WHERE {predicate}
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
LIMIT %s OFFSET %s
""",
tuple([*match_params, limit, offset]),
) or []
return {"items": rows, "total": int((total_row or {}).get("count") or 0), "limit": limit, "offset": offset}
@router.get("/customers/{customer_id}/utility-company")
async def get_customer_utility_company(customer_id: int):
@ -739,8 +812,8 @@ async def create_customer(customer: CustomerCreate):
customer_id = execute_insert(
"""INSERT INTO customers
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
country, website, is_active, invoice_email, mobile_phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
country, website, is_active, invoice_email, mobile_phone, supplier_service_enrolled)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
customer.name,
@ -755,7 +828,8 @@ async def create_customer(customer: CustomerCreate):
customer.website,
customer.is_active,
customer.invoice_email,
customer.mobile_phone
customer.mobile_phone,
customer.supplier_service_enrolled
)
)

View File

@ -884,6 +884,11 @@
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#emails">
<i class="bi bi-envelope"></i>E-mails
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt
@ -1047,6 +1052,13 @@
<span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span>
</div>
<div class="info-row">
<label class="info-label form-check-label" for="supplierServiceEnrolled">Benytter leverandørservice</label>
<div class="text-end">
<input class="form-check-input" type="checkbox" id="supplierServiceEnrolled" onchange="saveSupplierServiceEnrollment(this)">
<div id="supplierServiceStatus" class="small text-muted mt-1"></div>
</div>
</div>
</div>
</div>
@ -1216,6 +1228,19 @@
</div>
</div>
<!-- Emails Tab -->
<div class="tab-pane fade" id="emails">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kundens e-mails</h5>
<small class="text-muted">Koblede mails og sikre adresse-/domænematch</small>
</div>
<span id="customerEmailsCount" class="badge bg-secondary">0</span>
</div>
<div id="customerEmailsContainer" class="table-responsive"></div>
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4">
@ -2296,6 +2321,8 @@ let customerKontaktFilter = 'all';
let customerInvoicesLoaded = false;
let customerInvoicesData = [];
let customerInvoiceSearchTerm = '';
let customerEmailsOffset = 0;
const customerEmailsLimit = 25;
let eventListenersAdded = false;
@ -2327,6 +2354,11 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const emailsTab = document.querySelector('a[href="#emails"]');
if (emailsTab) {
emailsTab.addEventListener('shown.bs.tab', () => loadCustomerEmails(), { once: false });
}
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
@ -2730,6 +2762,7 @@ function displayCustomer(customer) {
document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>';
document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true;
// Integration
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
@ -2745,6 +2778,68 @@ function displayCustomer(customer) {
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container');
}
async function saveSupplierServiceEnrollment(checkbox) {
const previous = !checkbox.checked;
const status = document.getElementById('supplierServiceStatus');
checkbox.disabled = true;
status.className = 'small text-muted mt-1';
status.textContent = 'Gemmer…';
try {
const response = await fetch(`/api/v1/customers/${customerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_service_enrolled: checkbox.checked })
});
if (!response.ok) throw new Error('Kunne ikke gemme ændringen');
customerData.supplier_service_enrolled = checkbox.checked;
status.className = 'small text-success mt-1';
status.textContent = 'Gemt';
} catch (error) {
checkbox.checked = previous;
status.className = 'small text-danger mt-1';
status.textContent = error.message;
} finally {
checkbox.disabled = false;
}
}
function formatCrmDate(value) {
return value ? new Date(value).toLocaleString('da-DK') : '-';
}
async function loadCustomerEmails(offset = customerEmailsOffset) {
customerEmailsOffset = Math.max(0, offset);
const container = document.getElementById('customerEmailsContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/emails?limit=${customerEmailsLimit}&offset=${customerEmailsOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
const data = await response.json();
document.getElementById('customerEmailsCount').textContent = data.total;
if (!data.items.length) {
container.innerHTML = '<div class="text-center text-muted py-5">Ingen e-mails fundet</div>';
} else {
container.innerHTML = `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'">
<td>${escapeHtml(formatCrmDate(email.received_date))}</td>
<td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td>
<td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td>
<td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td>
<td>${email.has_attachments ? `<i class="bi bi-paperclip" title="${email.attachment_count || 1} vedhæftning(er)"></i>` : ''}</td>
</tr>`).join('')}</tbody></table>`;
}
const prev = Math.max(0, customerEmailsOffset - customerEmailsLimit);
const next = customerEmailsOffset + customerEmailsLimit;
document.getElementById('customerEmailsPagination').innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${customerEmailsOffset === 0 ? 'disabled' : ''} onclick="loadCustomerEmails(${prev})">Forrige</button>
<span class="small text-muted">${data.total ? customerEmailsOffset + 1 : 0}${Math.min(next, data.total)} af ${data.total}</span>
<button class="btn btn-sm btn-outline-secondary" ${next >= data.total ? 'disabled' : ''} onclick="loadCustomerEmails(${next})">Næste</button>`;
} catch (error) {
container.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('customerEmailsPagination').innerHTML = '';
}
}
function renderCustomerCallNumber(number) {
const clean = normalizePhoneValue(number);
if (!clean) return '-';

View File

@ -11,6 +11,11 @@ import json
from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection
from app.services.subscription_billing_calendar import (
advance_billing_periods,
billing_date_for_period,
prorated_30_day_factor,
)
logger = logging.getLogger(__name__)
@ -26,6 +31,13 @@ async def process_subscriptions():
try:
logger.info("💰 Processing subscription invoices...")
from app.subscriptions.backend.router import apply_due_subscription_changes, expire_ended_subscriptions
applied_changes = apply_due_subscription_changes()
expired_subscriptions = expire_ended_subscriptions()
if applied_changes:
logger.info("✅ Applied %s scheduled subscription change request(s)", applied_changes)
if expired_subscriptions:
logger.info("✅ Expired %s ended subscription(s)", expired_subscriptions)
# Find subscriptions due for invoicing
query = """
@ -37,8 +49,13 @@ async def process_subscriptions():
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_schedule_type,
s.billing_day,
s.billing_direction,
s.advance_months,
s.billing_lead_months,
s.first_full_period_start,
s.proration_basis,
s.price,
s.next_invoice_date,
s.period_start,
@ -67,11 +84,30 @@ async def process_subscriptions():
),
'[]'::json
) as line_items
,COALESCE(
(
SELECT json_agg(json_build_object(
'id', fi.id,
'description', fi.description,
'quantity', fi.quantity,
'unit_price', fi.unit_price,
'line_total', fi.line_total,
'product_id', fi.product_id
) ORDER BY fi.line_no, fi.id)
FROM sag_subscription_first_invoice_items fi
WHERE fi.subscription_id = s.id AND fi.billed_at IS NULL
),
'[]'::json
) AS first_invoice_items
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.status = 'active'
AND s.next_invoice_date <= CURRENT_DATE
AND NOT EXISTS (
SELECT 1 FROM subscription_billing_runs br
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
)
ORDER BY s.next_invoice_date, s.id
"""
@ -146,6 +182,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
coverage_start = None
coverage_end = None
# Claim every subscription period. A competing worker will make this
# transaction roll back before an order draft can be duplicated.
claimed_run_ids = []
for sub in subscriptions:
cursor.execute(
"""INSERT INTO subscription_billing_runs (subscription_id, period_start)
VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING id""",
(int(sub['id']), sub.get('period_start') or sub.get('next_invoice_date')),
)
claimed = cursor.fetchone()
if not claimed:
conn.rollback()
logger.info("Subscription period already claimed by another worker")
return 0
claimed_run_ids.append(int(claimed[0]))
for sub in subscriptions:
subscription_id = int(sub['id'])
source_subscription_ids.append(subscription_id)
@ -155,7 +207,13 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
line_items = json.loads(line_items)
period_start = sub.get('period_start') or sub.get('next_invoice_date')
period_end = _calculate_next_period_start(period_start, sub['billing_interval'])
first_full_period_start = sub.get('first_full_period_start')
if isinstance(first_full_period_start, str):
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
advance_periods = max(1, int(sub.get('advance_months') or 1))
has_short_opening_period = bool(first_full_period_start and period_start < first_full_period_start)
full_period_start = first_full_period_start if has_short_opening_period else period_start
period_end = advance_billing_periods(full_period_start, sub['billing_interval'], advance_periods)
if coverage_start is None or period_start < coverage_start:
coverage_start = period_start
if coverage_end is None or period_end > coverage_end:
@ -171,9 +229,53 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
continue
product_number = str(item.get('product_id', 'SUB'))
if has_short_opening_period:
factor = prorated_30_day_factor(period_start, first_full_period_start)
if factor > 0:
prorated_unit_price = round(float(item.get('unit_price', 0)) * factor, 2)
ordre_lines.append({
"product": {
"productNumber": product_number,
"description": f"{item.get('description', '')} skæv periode {period_start} til {first_full_period_start} (30 dage)"
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": prorated_unit_price,
"totalNetAmount": round(float(item.get('quantity', 1)) * prorated_unit_price, 2),
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"proration_basis": "30_day",
"proration_factor": factor,
"period_from": str(period_start),
"period_to": str(first_full_period_start),
}
})
full_unit_price = float(item.get('unit_price', 0)) * advance_periods
ordre_lines.append({
"product": {
"productNumber": product_number,
"description": item.get('description', '') + (f" {advance_periods} perioder" if advance_periods > 1 else '')
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": full_unit_price,
"totalNetAmount": float(item.get('quantity', 1)) * full_unit_price,
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"asset_id": item.get('asset_id'),
"period_from": str(full_period_start),
"period_to": str(item.get('period_to') or period_end),
"advance_periods": advance_periods,
}
})
first_invoice_items = sub.get('first_invoice_items', [])
if isinstance(first_invoice_items, str):
first_invoice_items = json.loads(first_invoice_items)
for item in first_invoice_items:
ordre_lines.append({
"product": {
"productNumber": str(item.get('product_id') or 'ENGANG'),
"description": item.get('description', '')
},
"quantity": float(item.get('quantity', 1)),
@ -182,9 +284,8 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"asset_id": item.get('asset_id'),
"period_from": str(item.get('period_from') or period_start),
"period_to": str(item.get('period_to') or period_end),
"first_invoice_item_id": item.get('id'),
"one_time": True,
}
})
@ -237,6 +338,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
))
ordre_id = cursor.fetchone()[0]
cursor.execute(
"UPDATE subscription_billing_runs SET ordre_draft_id = %s WHERE id = ANY(%s)",
(ordre_id, claimed_run_ids),
)
for sub, run_id in zip(subscriptions, claimed_run_ids):
first_items = sub.get('first_invoice_items', [])
if isinstance(first_items, str):
first_items = json.loads(first_items)
first_item_ids = [int(item['id']) for item in first_items if item.get('id')]
if first_item_ids:
cursor.execute(
"""UPDATE sag_subscription_first_invoice_items
SET billed_at = CURRENT_TIMESTAMP, billing_run_id = %s, updated_at = CURRENT_TIMESTAMP
WHERE subscription_id = %s AND id = ANY(%s) AND billed_at IS NULL""",
(run_id, int(sub['id']), first_item_ids),
)
logger.info(
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
ordre_id,
@ -246,8 +363,19 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
for sub in subscriptions:
subscription_id = int(sub['id'])
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval'])
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
first_full_period_start = sub.get('first_full_period_start')
if isinstance(first_full_period_start, str):
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
full_period_start = first_full_period_start if first_full_period_start and current_period_start < first_full_period_start else current_period_start
new_period_start = advance_billing_periods(
full_period_start, sub['billing_interval'], max(1, int(sub.get('advance_months') or 1))
)
new_next_invoice_date = billing_date_for_period(
new_period_start,
int(sub.get('billing_lead_months') or 0),
sub.get('billing_schedule_type') or 'fixed_day',
int(sub.get('billing_day') or 1),
)
cursor.execute(
"""

View File

@ -1002,7 +1002,9 @@ async def create_sag(request: Request, data: dict):
status = _normalize_case_status(data.get("status"))
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
current_user_id = _get_user_id_from_request(request)
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id")
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
_validate_user_id(ansvarlig_bruger_id)
@ -1089,7 +1091,7 @@ async def create_sag(request: Request, data: dict):
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, _get_user_id_from_request(request), deadline, deferred_until, data.get("deferred_until_case_id"),
assigned_group_id, current_user_id, 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()

View File

@ -1234,11 +1234,28 @@
updateCaseTypeSections();
}
async function selectCurrentUserAsResponsible() {
const select = document.getElementById('ansvarlig_bruger_id');
if (!select || select.value) return;
try {
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!response.ok) return;
const user = await response.json();
const userId = user.user_id ?? user.id;
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
select.value = String(userId);
}
} catch (error) {
console.error('Kunne ikke forvælge ansvarlig bruger', error);
}
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initializeSearch();
loadCaseTypesSelect();
loadPipelineStages();
selectCurrentUserAsResponsible();
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
applyTelefoniPrefill();
});

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,8 @@ import logging
import base64
import ipaddress
import re
import uuid
import math
from datetime import datetime
from typing import Optional
from urllib.error import URLError, HTTPError
@ -441,7 +443,8 @@ async def yealink_terminated(
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
return {"status": "ok"}
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None
return {"status": "ok", "time_entry_id": time_entry_id}
@router.websocket("/telefoni/ws")
@ -617,6 +620,76 @@ def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]
return str(value)
def _register_completed_call_time(callid: str) -> Optional[int]:
"""Create one completed worklog for a case-started call after PBX termination."""
call = execute_query_single(
"""
SELECT t.id, t.sag_id, t.bruger_id, t.started_at, t.ended_at, t.duration_sec,
t.raw_payload,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Hub User') AS user_name
FROM telefoni_opkald t
JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL
LEFT JOIN users u ON u.user_id = t.bruger_id
WHERE t.callid = %s
""",
(callid,),
)
if not call or not call.get("sag_id"):
return None
raw_payload = call.get("raw_payload") or {}
if isinstance(raw_payload, str):
try:
raw_payload = json.loads(raw_payload)
except (TypeError, ValueError):
raw_payload = {}
if not raw_payload.get("case_click_to_call"):
return None
marker = f"[telefoni:{call['id']}]"
existing = execute_query_single(
"SELECT id FROM tmodule_times WHERE sag_id = %s AND description LIKE %s LIMIT 1",
(call["sag_id"], f"%{marker}%"),
)
if existing:
return int(existing["id"])
from app.timetracking.backend.router import _resolve_case_customer_id
time_customer_id = _resolve_case_customer_id(call["sag_id"])
if not time_customer_id:
logger.warning("⚠️ Could not resolve time customer for call %s / case %s", callid, call["sag_id"])
return None
seconds = int(call.get("duration_sec") or 0)
if seconds <= 0 and call.get("started_at") and call.get("ended_at"):
seconds = max(0, int((call["ended_at"] - call["started_at"]).total_seconds()))
actual_minutes = max(1, math.ceil(seconds / 60))
billable_minutes = max(30, math.ceil(actual_minutes / 30) * 30)
row = execute_query_single(
"""
INSERT INTO tmodule_times (
sag_id, customer_id, description, original_hours, 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, work_type
) VALUES (
%s, %s, %s, %s, COALESCE(%s::timestamp, NOW())::date,
%s, 'pending', TRUE, %s, %s,
%s, %s, 'opkald', 'auto',
'afventer', %s, FALSE, 30,
FALSE, 'support'
) RETURNING id
""",
(
call["sag_id"], time_customer_id, f"Telefonopkald {marker}",
max(actual_minutes / 60.0, 0.01), call.get("started_at"), call.get("user_name"),
call.get("started_at"), call.get("ended_at"), actual_minutes, billable_minutes,
call.get("bruger_id"),
),
)
return int(row["id"]) if row else None
@router.post("/telefoni/click-to-call")
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
client_ip = _get_client_ip(request)
@ -706,6 +779,13 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
effective_user_id = int(authenticated_user_id or payload.user_id) if (authenticated_user_id or payload.user_id) else None
if payload.sag_id:
if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (payload.sag_id,)):
raise HTTPException(status_code=404, detail="Case not found")
if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)):
raise HTTPException(status_code=404, detail="Contact not found")
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
try:
@ -728,11 +808,29 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
if auth_header:
display_url = "[basic-auth] " + resolved_url
call_record = None
if payload.sag_id:
pending_callid = f"click-to-call:{uuid.uuid4()}"
call_record = execute_query_single(
"""
INSERT INTO telefoni_opkald
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, sag_id, started_at, raw_payload)
VALUES (%s, %s, 'outbound', %s, %s, %s, %s, NOW(), %s::jsonb)
RETURNING id, callid, sag_id, kontakt_id
""",
(
pending_callid, effective_user_id, number_normalized, extension_value or None,
payload.contact_id, payload.sag_id,
json.dumps({"case_click_to_call": True, "requested_number": number_normalized}),
),
)
return {
"status": "ok",
"action_url": display_url,
"http_status": status,
"number": number_normalized,
"call_record": call_record,
}

View File

@ -21,6 +21,8 @@ class TelefoniClickToCallRequest(BaseModel):
number: str
extension: Optional[str] = None
user_id: Optional[int] = None
sag_id: Optional[int] = None
contact_id: Optional[int] = None
class SmsSendRequest(BaseModel):

View File

@ -1,4 +1,5 @@
import logging
import re
from datetime import datetime
from typing import Any, Optional
@ -122,6 +123,52 @@ class TelefoniService:
raw_payload: Any,
started_at: datetime,
) -> dict:
# Reuse the temporary row created by a case click-to-call when the
# phone reports the real PBX call id. This preserves sag_id and avoids
# duplicate rows in the case call history.
if direction == "outbound" and user_id:
pending_rows = execute_query(
"""
SELECT * FROM telefoni_opkald
WHERE callid LIKE 'click-to-call:%'
AND direction = 'outbound'
AND bruger_id = %s
AND ended_at IS NULL
AND started_at >= NOW() - INTERVAL '3 minutes'
ORDER BY started_at DESC
LIMIT 10
""",
(user_id,),
) or []
target_digits = re.sub(r"\D", "", str(ekstern_nummer or ""))[-8:]
pending = next(
(
row for row in pending_rows
if (kontakt_id and row.get("kontakt_id") == kontakt_id)
or (
target_digits
and re.sub(r"\D", "", str(row.get("ekstern_nummer") or "")).endswith(target_digits)
)
),
None,
)
if pending:
rows = execute_query(
"""
UPDATE telefoni_opkald
SET callid = %s,
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || %s::jsonb,
intern_extension = COALESCE(intern_extension, %s),
ekstern_nummer = COALESCE(%s, ekstern_nummer),
kontakt_id = COALESCE(kontakt_id, %s),
started_at = LEAST(started_at, %s)
WHERE id = %s
RETURNING *
""",
(callid, raw_payload, intern_extension, ekstern_nummer, kontakt_id, started_at, pending["id"]),
)
return rows[0] if rows else {}
query = """
INSERT INTO telefoni_opkald
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)

View File

@ -0,0 +1 @@
"""Administration of content published on bmcnetworks.dk."""

View File

@ -0,0 +1 @@
"""Website content backend."""

View File

@ -0,0 +1,152 @@
from __future__ import annotations
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from PIL import Image, UnidentifiedImageError
from app.core.auth_dependencies import require_permission
from app.modules.website_content.backend.schemas import (
CompleteOperation,
CustomerReferenceCreate,
CustomerReferenceUpdate,
IncidentCreate,
IncidentUpdate,
OperationCreate,
OperationUpdate,
)
from app.modules.website_content.backend.service import NotFoundError, WebsiteContentAPIError, website_content_service
router = APIRouter()
ALLOWED_LOGOS = {"image/png", "image/jpeg", "image/webp", "image/gif"}
MAX_LOGO_BYTES = 5 * 1024 * 1024
def call_service(method, *args, **kwargs):
try:
return method(*args, **kwargs)
except NotFoundError:
raise HTTPException(status_code=404, detail="Indholdet blev ikke fundet")
except WebsiteContentAPIError as exc:
raise HTTPException(status_code=503, detail=str(exc))
@router.get("/customers")
async def list_customers(
include_hidden: bool = Query(True),
_user: dict = Depends(require_permission("website_content.view")),
):
return call_service(website_content_service.list, "customers", include_hidden)
@router.post("/customers", status_code=201)
async def create_customer(
payload: CustomerReferenceCreate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(website_content_service.create, "customers", payload.model_dump())
@router.patch("/customers/{item_id}")
async def update_customer(
item_id: int,
payload: CustomerReferenceUpdate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.update, "customers", item_id, payload.model_dump(exclude_unset=True)
)
@router.post("/customers/{item_id}/logo")
async def upload_customer_logo(
item_id: int,
logo: UploadFile = File(...),
_user: dict = Depends(require_permission("website_content.edit")),
):
if logo.content_type not in ALLOWED_LOGOS:
raise HTTPException(status_code=415, detail="Logo skal være PNG, JPEG, WebP eller GIF")
content = await logo.read(MAX_LOGO_BYTES + 1)
if not content or len(content) > MAX_LOGO_BYTES:
raise HTTPException(status_code=413, detail="Logo må højst fylde 5 MB")
try:
image = Image.open(BytesIO(content))
image.verify()
except (UnidentifiedImageError, OSError):
raise HTTPException(status_code=422, detail="Filen er ikke et gyldigt billede")
return call_service(website_content_service.upload_logo, item_id, content, logo.content_type)
@router.get("/customers/{item_id}/logo")
async def preview_customer_logo(
item_id: int,
_user: dict = Depends(require_permission("website_content.view")),
):
content, mime_type = call_service(website_content_service.logo, item_id)
return Response(content=content, media_type=mime_type, headers={"Cache-Control": "private, max-age=60"})
@router.get("/operations")
async def list_operations(
include_hidden: bool = Query(True),
_user: dict = Depends(require_permission("website_content.view")),
):
return call_service(website_content_service.list, "operations", include_hidden)
@router.post("/operations", status_code=201)
async def create_operation(
payload: OperationCreate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(website_content_service.create, "operations", payload.model_dump())
@router.patch("/operations/{item_id}")
async def update_operation(
item_id: int,
payload: OperationUpdate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.update, "operations", item_id, payload.model_dump(exclude_unset=True)
)
@router.post("/operations/{item_id}/complete", status_code=201)
async def complete_operation(
item_id: int,
payload: CompleteOperation,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.complete_operation, item_id, payload.ends_at, payload.is_public
)
@router.get("/incidents")
async def list_incidents(
include_hidden: bool = Query(True),
_user: dict = Depends(require_permission("website_content.view")),
):
return call_service(website_content_service.list, "incidents", include_hidden)
@router.post("/incidents", status_code=201)
async def create_incident(
payload: IncidentCreate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(website_content_service.create, "incidents", payload.model_dump())
@router.patch("/incidents/{item_id}")
async def update_incident(
item_id: int,
payload: IncidentUpdate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.update, "incidents", item_id, payload.model_dump(exclude_unset=True)
)

View File

@ -0,0 +1,69 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl, model_validator
Severity = Literal["ok", "info", "warning", "critical"]
class CustomerReferenceCreate(BaseModel):
customer_name: str = Field(min_length=1, max_length=180)
logo_url: str | None = Field(default=None, max_length=500)
website_url: HttpUrl | None = None
sort_order: int = Field(default=100, ge=0, le=100000)
is_active: bool = True
class CustomerReferenceUpdate(BaseModel):
customer_name: str | None = Field(default=None, min_length=1, max_length=180)
logo_url: str | None = Field(default=None, max_length=500)
website_url: HttpUrl | None = None
sort_order: int | None = Field(default=None, ge=0, le=100000)
is_active: bool | None = None
class OperationCreate(BaseModel):
title: str = Field(min_length=1, max_length=180)
severity: Severity = "info"
message: str = Field(min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_active: bool = True
@model_validator(mode="after")
def validate_dates(self):
if self.starts_at and self.ends_at and self.ends_at < self.starts_at:
raise ValueError("Sluttid skal ligge efter starttid")
return self
class OperationUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=180)
severity: Severity | None = None
message: str | None = Field(default=None, min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_active: bool | None = None
class IncidentCreate(BaseModel):
title: str = Field(min_length=1, max_length=180)
severity: Severity = "info"
message: str = Field(min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_public: bool = True
class IncidentUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=180)
severity: Severity | None = None
message: str | None = Field(default=None, min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_public: bool | None = None
class CompleteOperation(BaseModel):
ends_at: datetime | None = None
is_public: bool = True

View File

@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import httpx
from app.core.config import settings
class NotFoundError(RuntimeError):
pass
class WebsiteContentAPIError(RuntimeError):
pass
class WebsiteContentService:
"""Authenticated HTTPS client for the website administration API."""
def __init__(self, api_url=None, api_token=None, client: httpx.Client | None = None):
self.api_url = (api_url or settings.WEBSITE_CONTENT_API_URL).rstrip("/")
explicit_token = api_token is not None
self.api_token = api_token if explicit_token else settings.WEBSITE_CONTENT_API_TOKEN
if not explicit_token and not self.api_token and settings.WEBSITE_CONTENT_MYSQL_PASSWORD:
self.api_token = settings.WEBSITE_CONTENT_MYSQL_PASSWORD
self.client = client or httpx.Client(timeout=settings.WEBSITE_CONTENT_API_TIMEOUT_SECONDS)
def _request(self, method: str, resource: str, *, item_id=None, action=None,
data=None, files=None, expect_bytes=False, extra_params=None):
if not self.api_url or not self.api_token:
raise WebsiteContentAPIError("Website admin-API er ikke konfigureret")
params = {"resource": resource, **(extra_params or {})}
if item_id is not None:
params["id"] = item_id
if action:
params["action"] = action
headers = {"X-Website-Admin-Token": self.api_token, "Accept": "application/json"}
if settings.WEBSITE_CONTENT_API_HOST_HEADER:
headers["Host"] = settings.WEBSITE_CONTENT_API_HOST_HEADER
try:
response = self.client.request(
method, self.api_url, params=params, headers=headers,
json=self._json_values(data) if data is not None and files is None else None,
files=files,
)
except httpx.HTTPError as exc:
raise WebsiteContentAPIError("Kunne ikke kontakte website admin-API") from exc
if response.status_code == 404:
raise NotFoundError()
if not response.is_success:
try:
payload = response.json()
detail = payload.get("message") or payload.get("error")
except (ValueError, AttributeError):
detail = None
raise WebsiteContentAPIError(detail or f"Website admin-API svarede {response.status_code}")
if expect_bytes:
return response.content, response.headers.get("content-type", "application/octet-stream")
return response.json()
def list(self, kind: str, include_hidden: bool = True) -> list[dict[str, Any]]:
result = self._request("GET", kind, extra_params={"include_hidden": int(include_hidden)})
return result.get("items", result) if isinstance(result, dict) else result
def get(self, kind: str, item_id: int) -> dict[str, Any]:
return self._request("GET", kind, item_id=item_id)
def create(self, kind: str, data: dict[str, Any]) -> dict[str, Any]:
return self._request("POST", kind, data=data)
def update(self, kind: str, item_id: int, data: dict[str, Any]) -> dict[str, Any]:
return self._request("PATCH", kind, item_id=item_id, data=data)
def upload_logo(self, item_id: int, content: bytes, mime_type: str) -> dict[str, Any]:
return self._request("POST", "customers", item_id=item_id, action="logo",
files={"logo": ("logo", content, mime_type)})
def logo(self, item_id: int) -> tuple[bytes, str]:
return self._request("GET", "customers", item_id=item_id, action="logo", expect_bytes=True)
def complete_operation(self, item_id: int, ends_at: datetime | None, is_public: bool):
return self._request("POST", "operations", item_id=item_id, action="complete",
data={"ends_at": ends_at, "is_public": is_public})
@classmethod
def _json_values(cls, data):
if data is None:
return None
return {key: value.isoformat() if isinstance(value, datetime)
else str(value) if cls._is_url(value) else value for key, value in data.items()}
@staticmethod
def _is_url(value: Any) -> bool:
return value is not None and value.__class__.__module__.startswith("pydantic.networks")
website_content_service = WebsiteContentService()

View File

@ -0,0 +1 @@
"""Website content frontend."""

View File

@ -0,0 +1,19 @@
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("/website-content", response_class=HTMLResponse)
async def website_content_page(
request: Request,
current_user: dict = Depends(require_permission("website_content.view")),
):
return templates.TemplateResponse(
"modules/website_content/templates/index.html",
{"request": request, "current_user": current_user},
)

View File

@ -0,0 +1,11 @@
{
"name": "website_content",
"version": "1.0.0",
"description": "Administration af kundereferencer og offentlig driftsstatus på bmcnetworks.dk.",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "",
"api_prefix": "/api/v1/website-content",
"tags": ["Website", "Indhold", "Driftsstatus"]
}

View File

@ -0,0 +1,127 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Website-indhold · BMC Hub{% endblock %}
{% block extra_css %}
<style>
.wc-shell { max-width: 1500px; margin: 0 auto; }
.wc-hero { background: linear-gradient(135deg, #06364b, #087894); color: #fff; border-radius: 22px; padding: 1.5rem; box-shadow: 0 14px 35px rgba(5,54,75,.2); }
.wc-kicker { text-transform: uppercase; letter-spacing: .13em; font-size: .72rem; opacity: .74; font-weight: 700; }
.wc-panel { background: var(--card-bg, #fff); color: var(--text-color, #17202a); border: 1px solid var(--border-color, #dde4e8); border-radius: 18px; overflow: hidden; }
.wc-panel-head { padding: 1rem 1.15rem; border-bottom: 1px solid var(--border-color, #dde4e8); display:flex; align-items:center; justify-content:space-between; gap:1rem; }
.wc-list { display:grid; gap:.75rem; padding:1rem; }
.wc-row { border:1px solid var(--border-color, #dde4e8); border-radius:14px; padding:1rem; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:1rem; align-items:center; background:var(--body-bg, #fff); }
.wc-logo { width:92px; height:54px; object-fit:contain; border-radius:8px; padding:5px; background:#fff; border:1px solid #e5e7eb; }
.wc-meta { color:var(--text-muted, #68737d); font-size:.84rem; }
.wc-severity { display:inline-flex; align-items:center; gap:.35rem; border-radius:999px; padding:.25rem .6rem; font-size:.75rem; font-weight:700; }
.wc-severity.ok { color:#087b4d; background:#d9f8ea; } .wc-severity.info { color:#1261a0; background:#ddebfa; }
.wc-severity.warning { color:#926300; background:#fff0c2; } .wc-severity.critical { color:#b42318; background:#fee4e2; }
.wc-hidden { opacity:.55; }
.wc-empty { text-align:center; color:var(--text-muted, #68737d); padding:2.5rem 1rem; }
.wc-tabs { display:flex; gap:.5rem; overflow:auto; padding-bottom:.25rem; }
.wc-tabs button { white-space:nowrap; border-radius:999px; }
.form-control, .form-select { background-color:var(--card-bg, #fff); color:var(--text-color, #17202a); border-color:var(--border-color, #ced4da); }
@media(max-width:700px) { .wc-row { grid-template-columns:1fr; } .wc-actions { width:100%; display:flex; } .wc-actions .btn { flex:1; } }
</style>
{% endblock %}
{% block content %}
{% set can_edit = current_user.is_superadmin or 'website_content.edit' in (current_user.permissions or []) %}
<main class="container-fluid py-4 px-lg-4 wc-shell" data-can-edit="{{ 'true' if can_edit else 'false' }}">
<section class="wc-hero mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
<div><div class="wc-kicker mb-2">Nordic Top · website administration</div><h1 class="h3 mb-2">Website-indhold</h1><p class="mb-0 opacity-75">Administrér kundelogoer, aktuel driftsstatus og offentlig historik.</p></div>
<div id="connectionState" class="small opacity-75"><i class="bi bi-circle-fill me-1"></i> Forbinder…</div>
</section>
<div class="wc-tabs mb-3" role="tablist">
<button class="btn btn-primary" data-tab="customers"><i class="bi bi-buildings me-1"></i>Kundereferencer</button>
<button class="btn btn-outline-secondary" data-tab="operations"><i class="bi bi-broadcast me-1"></i>Aktuel drift</button>
<button class="btn btn-outline-secondary" data-tab="incidents"><i class="bi bi-clock-history me-1"></i>Historik</button>
</div>
<section class="wc-panel" id="contentPanel">
<div class="wc-panel-head">
<div><h2 class="h5 mb-1" id="panelTitle">Kundereferencer</h2><div class="wc-meta" id="panelSubtitle">Rækkefølge og logoer på forsiden</div></div>
{% if can_edit %}<button class="btn btn-primary" id="createBtn"><i class="bi bi-plus-lg me-1"></i>Opret</button>{% endif %}
</div>
<div class="wc-list" id="contentList"><div class="wc-empty"><div class="spinner-border spinner-border-sm me-2"></div>Indlæser…</div></div>
</section>
</main>
<div class="modal fade" id="editorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="editorForm">
<div class="modal-header"><h2 class="modal-title fs-5" id="editorTitle">Redigér</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="editorFields"></div>
<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div>
</form></div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3"><div id="wcToast" class="toast"><div class="toast-body" id="wcToastText"></div></div></div>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const api = '/api/v1/website-content';
const canEdit = document.querySelector('.wc-shell').dataset.canEdit === 'true';
const labels = {
customers: ['Kundereferencer', 'Rækkefølge og logoer på forsiden'],
operations: ['Aktuel driftsstatus', 'Aktive, kommende og skjulte meddelelser'],
incidents: ['Driftshistorik', 'Offentlige og skjulte afsluttede hændelser']
};
let tab = 'customers', items = [], editing = null;
const modal = new bootstrap.Modal(document.getElementById('editorModal'));
const esc = value => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const localInput = value => value ? String(value).replace(' ', 'T').slice(0,16) : '';
const formatDate = value => value ? new Intl.DateTimeFormat('da-DK', {dateStyle:'medium', timeStyle:'short'}).format(new Date(String(value).replace(' ', 'T'))) : 'Ikke angivet';
const toast = (message, error=false) => { const el=document.getElementById('wcToast'); el.classList.toggle('text-bg-danger', error); el.classList.toggle('text-bg-success', !error); document.getElementById('wcToastText').textContent=message; bootstrap.Toast.getOrCreateInstance(el).show(); };
async function request(path, options={}) {
const response = await fetch(api + path, {credentials:'include', ...options});
if (!response.ok) { let detail='Handlingen mislykkedes'; try { detail=(await response.json()).detail || detail; } catch {} throw new Error(detail); }
return response.status === 204 ? null : response.json();
}
async function load() {
const list=document.getElementById('contentList'); list.innerHTML='<div class="wc-empty"><div class="spinner-border spinner-border-sm me-2"></div>Indlæser…</div>';
try { items=await request(`/${tab}`); render(); document.getElementById('connectionState').innerHTML='<i class="bi bi-check-circle-fill me-1"></i> Website-database forbundet'; }
catch(error) { list.innerHTML=`<div class="wc-empty text-danger"><i class="bi bi-exclamation-triangle fs-3 d-block mb-2"></i>${esc(error.message)}</div>`; document.getElementById('connectionState').innerHTML='<i class="bi bi-x-circle-fill me-1"></i> Ikke forbundet'; }
}
function render() {
const list=document.getElementById('contentList');
if (!items.length) { list.innerHTML='<div class="wc-empty">Der er endnu ikke oprettet indhold her.</div>'; return; }
list.innerHTML=items.map(item => tab==='customers' ? customerRow(item) : operationRow(item)).join('');
list.querySelectorAll('[data-edit]').forEach(button => button.onclick=()=>openEditor(Number(button.dataset.edit)));
list.querySelectorAll('[data-toggle]').forEach(button => button.onclick=()=>toggleVisibility(Number(button.dataset.toggle)));
list.querySelectorAll('[data-complete]').forEach(button => button.onclick=()=>complete(Number(button.dataset.complete)));
}
function customerRow(item) {
const active=!!item.is_active, preview=`${api}/customers/${item.id}/logo?v=${encodeURIComponent(item.updated_at || '')}`;
return `<article class="wc-row ${active?'':'wc-hidden'}"><div class="d-flex align-items-center gap-3"><img class="wc-logo" src="${preview}" onerror="this.onerror=null;this.src='${esc(item.logo_url || '')}'" alt=""><div><div class="fw-semibold">${esc(item.customer_name)}</div><div class="wc-meta">Placering ${item.sort_order} · ${active?'Synlig':'Skjult'}</div>${item.website_url?`<a class="small" href="${esc(item.website_url)}" target="_blank" rel="noopener">${esc(item.website_url)}</a>`:''}</div></div>${actions(item, active)}`;
}
function operationRow(item) {
const visibility=tab==='operations' ? !!item.is_active : !!item.is_public;
return `<article class="wc-row ${visibility?'':'wc-hidden'}"><div><div class="d-flex flex-wrap align-items-center gap-2 mb-1"><span class="wc-severity ${item.severity}">${esc(item.severity)}</span><strong>${esc(item.title)}</strong></div><div class="mb-2">${esc(item.message)}</div><div class="wc-meta">${formatDate(item.starts_at)} → ${formatDate(item.ends_at)} · ${visibility?(tab==='operations'?'Aktiv':'Offentlig'):'Skjult'}</div></div>${actions(item, visibility)}`;
}
function actions(item, visible) {
if (!canEdit) return '';
return `<div class="wc-actions d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" data-edit="${item.id}"><i class="bi bi-pencil"></i><span class="visually-hidden">Redigér</span></button>${tab==='operations'&&item.is_active?`<button class="btn btn-sm btn-success" data-complete="${item.id}"><i class="bi bi-check2-circle me-1"></i>Afslut</button>`:''}<button class="btn btn-sm btn-outline-secondary" data-toggle="${item.id}">${visible?'Skjul':'Vis'}</button></div>`;
}
function openEditor(id=null) {
editing=id ? items.find(item=>item.id===id) : null;
document.getElementById('editorTitle').textContent=(editing?'Redigér ':'Opret ') + labels[tab][0].toLowerCase();
document.getElementById('editorFields').innerHTML=tab==='customers' ? customerFields(editing||{}) : operationFields(editing||{});
modal.show();
}
function customerFields(v) { return `<div class="row g-3"><div class="col-md-8"><label class="form-label">Kundenavn</label><input class="form-control" name="customer_name" maxlength="180" required value="${esc(v.customer_name)}"></div><div class="col-md-4"><label class="form-label">Rækkefølge</label><input class="form-control" type="number" min="0" name="sort_order" value="${v.sort_order ?? 100}"></div><div class="col-12"><label class="form-label">Website-link</label><input class="form-control" type="url" name="website_url" value="${esc(v.website_url)}" placeholder="https://…"></div><div class="col-12"><label class="form-label">Logo</label><input class="form-control" type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/gif"><div class="form-text">PNG, JPEG, WebP eller GIF · maks. 5 MB. Tomt felt beholder eksisterende logo.</div><img id="logoPreview" class="wc-logo mt-2 d-none" alt="Logo-preview"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_active" id="activeCheck" ${v.is_active===0?'':'checked'}><label class="form-check-label" for="activeCheck">Synlig på websitet</label></div></div>`; }
function operationFields(v) { const visibility=tab==='operations'?'is_active':'is_public'; return `<div class="row g-3"><div class="col-md-8"><label class="form-label">Titel</label><input class="form-control" name="title" maxlength="180" required value="${esc(v.title)}"></div><div class="col-md-4"><label class="form-label">Severity</label><select class="form-select" name="severity">${['ok','info','warning','critical'].map(x=>`<option ${v.severity===x?'selected':''}>${x}</option>`).join('')}</select></div><div class="col-12"><label class="form-label">Meddelelse</label><textarea class="form-control" name="message" rows="5" required>${esc(v.message)}</textarea></div><div class="col-md-6"><label class="form-label">Planlagt start</label><input class="form-control" type="datetime-local" name="starts_at" value="${localInput(v.starts_at)}"></div><div class="col-md-6"><label class="form-label">Planlagt slut</label><input class="form-control" type="datetime-local" name="ends_at" value="${localInput(v.ends_at)}"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="${visibility}" id="visibleCheck" ${v[visibility]===0?'':'checked'}><label class="form-check-label" for="visibleCheck">${tab==='operations'?'Aktiv':'Offentlig'}</label></div></div>`; }
document.getElementById('editorFields').addEventListener('change', event => { if(event.target.name==='logo'&&event.target.files[0]) { const p=document.getElementById('logoPreview'); p.src=URL.createObjectURL(event.target.files[0]); p.classList.remove('d-none'); } });
document.getElementById('editorForm').onsubmit=async event => {
event.preventDefault(); const form=new FormData(event.target), path=`/${tab}${editing?'/'+editing.id:''}`;
const payload=tab==='customers' ? {customer_name:form.get('customer_name'), website_url:form.get('website_url')||null, sort_order:Number(form.get('sort_order')), is_active:form.has('is_active')} : {title:form.get('title'), severity:form.get('severity'), message:form.get('message'), starts_at:form.get('starts_at')||null, ends_at:form.get('ends_at')||null, [tab==='operations'?'is_active':'is_public']:form.has(tab==='operations'?'is_active':'is_public')};
try { const saved=await request(path,{method:editing?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const logo=form.get('logo'); if(tab==='customers'&&logo&&logo.size){const data=new FormData();data.append('logo',logo);await request(`/customers/${saved.id}/logo`,{method:'POST',body:data});} modal.hide(); toast('Indholdet er gemt'); await load(); } catch(error){toast(error.message,true);}
};
async function toggleVisibility(id) { const item=items.find(x=>x.id===id), field=tab==='incidents'?'is_public':'is_active'; try{await request(`/${tab}/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({[field]:!item[field]})});await load();}catch(error){toast(error.message,true);} }
async function complete(id) { if(!confirm('Afslut hændelsen og flyt den til historikken?'))return; try{await request(`/operations/${id}/complete`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_public:true})});toast('Hændelsen er flyttet til historikken');await load();}catch(error){toast(error.message,true);} }
document.querySelectorAll('[data-tab]').forEach(button=>button.onclick=()=>{tab=button.dataset.tab;document.querySelectorAll('[data-tab]').forEach(x=>x.className='btn btn-outline-secondary');button.className='btn btn-primary';document.getElementById('panelTitle').textContent=labels[tab][0];document.getElementById('panelSubtitle').textContent=labels[tab][1];load();});
document.getElementById('createBtn')?.addEventListener('click',()=>openEditor()); load();
})();
</script>
{% endblock %}

View File

@ -0,0 +1,28 @@
"""Pure agreement status rules shared by API and tests."""
from typing import Any, Dict, List
def agreement_status(subscriptions: List[Dict[str, Any]], changes: List[Dict[str, Any]]) -> str:
change_statuses = {row.get("status") for row in changes}
if change_statuses.intersection({"failed", "partially_applied"}):
return "Kræver handling"
if "pending" in change_statuses or "draft" in change_statuses:
return "Ændring afventer"
if change_statuses.intersection({"approved_scheduled", "applying"}):
return "Planlagt ændring"
statuses = [row.get("status") for row in subscriptions]
if not statuses or all(status in {"cancelled", "expired"} for status in statuses):
return "Afsluttet"
live = [status for status in statuses if status not in {"cancelled", "expired"}]
if live and all(status == "terminating" for status in live):
return "Under opsigelse"
if any(status in {"cancelled", "expired", "terminating"} for status in statuses):
return "Delvist opsagt"
if any(status == "paused" for status in statuses) and any(status == "active" for status in statuses):
return "Delvist pauseret"
if any(status == "active" for status in statuses):
return "Aktiv"
if statuses and all(status == "scheduled" for status in statuses):
return "Planlagt"
return "Kladde"

View File

@ -0,0 +1,132 @@
"""Deterministic billing dates for Danish subscriptions."""
from __future__ import annotations
from calendar import monthrange
from datetime import date, timedelta
from typing import Optional
from dateutil.easter import easter
from dateutil.relativedelta import relativedelta
MONTH_BASED_INTERVALS = {"monthly", "quarterly", "yearly"}
SCHEDULE_TYPES = {"fixed_day", "first_business_day", "last_business_day", "interval_anchor"}
def validate_billing_schedule(
interval: str,
schedule_type: str,
billing_day: Optional[int],
) -> tuple[str, int]:
"""Return a runnable schedule or reject a combination the invoice job cannot execute."""
if interval not in {"daily", "biweekly", *MONTH_BASED_INTERVALS}:
raise ValueError("invalid billing_interval")
schedule_type = (schedule_type or "fixed_day").strip().lower()
day = int(billing_day or 1)
if interval in {"daily", "biweekly"}:
return "interval_anchor", day
if schedule_type == "interval_anchor":
raise ValueError("interval_anchor is only valid for daily and biweekly subscriptions")
if schedule_type not in SCHEDULE_TYPES:
raise ValueError("invalid billing_schedule_type")
if schedule_type == "fixed_day" and not 1 <= day <= 28:
raise ValueError("billing_day must be between 1 and 28")
return schedule_type, day
def danish_bank_holidays(year: int) -> set[date]:
"""Return Nationalbanken's recurring Danish bank closing days."""
easter_sunday = easter(year)
return {
date(year, 1, 1),
easter_sunday - timedelta(days=3), # Maundy Thursday
easter_sunday - timedelta(days=2), # Good Friday
easter_sunday + timedelta(days=1), # Easter Monday
easter_sunday + timedelta(days=39), # Ascension Day
easter_sunday + timedelta(days=40), # Bank holiday after Ascension
easter_sunday + timedelta(days=50), # Whit Monday
date(year, 6, 5),
date(year, 12, 24),
date(year, 12, 25),
date(year, 12, 26),
date(year, 12, 31),
}
def is_danish_bank_day(value: date) -> bool:
return value.weekday() < 5 and value not in danish_bank_holidays(value.year)
def resolve_month_date(year: int, month: int, schedule_type: str, billing_day: Optional[int]) -> date:
if schedule_type == "first_business_day":
candidate = date(year, month, 1)
while not is_danish_bank_day(candidate):
candidate += timedelta(days=1)
return candidate
if schedule_type == "last_business_day":
candidate = date(year, month, monthrange(year, month)[1])
while not is_danish_bank_day(candidate):
candidate -= timedelta(days=1)
return candidate
day = int(billing_day or 1)
if not 1 <= day <= 28:
raise ValueError("billing_day must be between 1 and 28")
return date(year, month, day)
def add_interval(value: date, interval: str) -> date:
if interval == "daily":
return value + timedelta(days=1)
if interval == "biweekly":
return value + timedelta(days=14)
if interval == "quarterly":
return value + relativedelta(months=3)
if interval == "yearly":
return value + relativedelta(years=1)
return value + relativedelta(months=1)
def next_billing_date(
anchor: date,
interval: str,
schedule_type: str = "fixed_day",
billing_day: Optional[int] = 1,
) -> date:
"""Advance one interval, then resolve the configured date in its target month."""
target = add_interval(anchor, interval)
if interval not in MONTH_BASED_INTERVALS or schedule_type == "interval_anchor":
return target
if schedule_type not in SCHEDULE_TYPES:
raise ValueError("invalid billing_schedule_type")
return resolve_month_date(target.year, target.month, schedule_type, billing_day)
def billing_date_for_period(
period_start: date,
lead_months: int,
schedule_type: str = "fixed_day",
billing_day: Optional[int] = 1,
) -> date:
"""Resolve the invoice date N calendar months before a coverage period starts."""
target = period_start - relativedelta(months=max(0, int(lead_months or 0)))
if schedule_type == "interval_anchor":
return target
return resolve_month_date(target.year, target.month, schedule_type, billing_day)
def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date:
"""Advance a coverage boundary by a number of complete billing periods."""
result = value
for _ in range(max(1, int(periods or 1))):
result = add_interval(result, interval)
return result
def prorated_30_day_factor(period_start: date, first_full_period_start: date) -> float:
"""30/360-style fraction for a short opening period, capped at one month."""
if period_start >= first_full_period_start:
return 0.0
months = (first_full_period_start.year - period_start.year) * 12 + first_full_period_start.month - period_start.month
synthetic_days = months * 30 + min(first_full_period_start.day, 30) - min(period_start.day, 30)
return max(0.0, min(float(synthetic_days) / 30.0, 1.0))

View File

@ -1104,6 +1104,7 @@
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Værktøjer</h6></li>
<li data-menu-key="menu-support-manual"><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
<li data-menu-key="menu-support-website-content"><a class="dropdown-item py-2" href="/website-content"><i class="bi bi-window-stack me-2"></i>Website-indhold</a></li>
</ul>
</li>
<li class="nav-item dropdown" data-menu-key="menu-salg">
@ -2629,6 +2630,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-support-hardware-customers', label: 'Support: Kundehardware' },
{ key: 'menu-support-eset', label: 'Support: ESET Oversigt' },
{ key: 'menu-support-manual', label: 'Support: Manualer' },
{ key: 'menu-support-website-content', label: 'Support: Website-indhold' },
{ key: 'menu-salg-orders', label: 'Salg: Ordre' },
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },

File diff suppressed because it is too large Load Diff

View File

@ -3,83 +3,82 @@
{% block title %}Abonnementer - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
<p class="text-muted">Alle solgte, aktive abonnementer</p>
<style>
.sub-shell{--ink:#14263b;--muted:#64748b;--line:#dbe5ef;--blue:#0e5d91;--cyan:#19a7b8;--mint:#20a36a;background:radial-gradient(circle at 8% 0%,rgba(25,167,184,.12),transparent 28%),linear-gradient(180deg,#f6fbff 0,#f8fafc 420px);min-height:calc(100vh - 64px);margin:-1.5rem;padding:1.5rem}
.sub-hero{position:relative;overflow:hidden;color:#fff;border-radius:26px;padding:2rem 2.2rem;background:linear-gradient(125deg,#0a3557 0%,#0d5f91 52%,#1598a8 100%);box-shadow:0 24px 60px rgba(14,73,112,.22)}
.sub-hero:after{content:"";position:absolute;width:340px;height:340px;border:70px solid rgba(255,255,255,.08);border-radius:50%;right:-90px;top:-150px}
.sub-kicker{font-size:.72rem;letter-spacing:.15em;text-transform:uppercase;font-weight:800;color:#9de8ef}.sub-title{font-size:clamp(1.8rem,4vw,3rem);font-weight:800;letter-spacing:-.04em}.sub-hero-copy{max-width:680px;color:rgba(255,255,255,.76)}
.sub-hero .btn{border-radius:12px;padding:.68rem 1rem;font-weight:650}.sub-hero .btn-light{color:#0d5f91}.sub-hero .btn-outline-light{border-color:rgba(255,255,255,.4)}
.sub-metric{height:100%;border:1px solid rgba(219,229,239,.85);border-radius:20px;background:rgba(255,255,255,.9);box-shadow:0 12px 34px rgba(30,64,99,.08);padding:1.15rem 1.25rem;backdrop-filter:blur(12px)}
.sub-metric-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:14px;background:#e8f6fb;color:var(--blue);font-size:1.15rem}.sub-metric-value{font-size:1.65rem;font-weight:800;letter-spacing:-.03em;color:var(--ink)}.sub-metric-label{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}
.sub-toolbar{border:1px solid var(--line);border-radius:18px;background:#fff;padding:.8rem;box-shadow:0 8px 26px rgba(30,64,99,.06)}.sub-search{border:0;background:#f1f6fa;border-radius:12px;padding:.7rem 1rem}.sub-search:focus{background:#fff;box-shadow:0 0 0 3px rgba(25,167,184,.13)}
.sub-filter-pills{display:flex;gap:.4rem;overflow:auto;padding-bottom:2px}.sub-filter-pill{white-space:nowrap;border:0;border-radius:999px;background:#edf3f7;color:#526476;padding:.55rem .85rem;font-size:.82rem;font-weight:700}.sub-filter-pill.active{background:var(--ink);color:#fff;box-shadow:0 6px 16px rgba(20,38,59,.18)}
.subscription-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:1rem}.sub-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:20px;background:#fff;box-shadow:0 8px 26px rgba(30,64,99,.07);transition:.2s ease}.sub-card:hover{transform:translateY(-3px);box-shadow:0 18px 42px rgba(30,64,99,.13);border-color:#b9d6e5}.sub-card-accent{height:5px;background:linear-gradient(90deg,var(--blue),var(--cyan))}.sub-card.is-blocked .sub-card-accent{background:#dc3545}.sub-card.is-ending .sub-card-accent{background:#e8a317}.sub-card-body{padding:1.2rem}.sub-avatar{display:grid;place-items:center;width:46px;height:46px;border-radius:15px;background:linear-gradient(135deg,#e6f5fb,#dcecff);color:var(--blue);font-weight:800}.sub-number{font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);font-weight:750}.sub-product{color:var(--ink);font-size:1.06rem;font-weight:800}.sub-price{font-size:1.7rem;line-height:1;font-weight:850;letter-spacing:-.04em;color:var(--ink)}
.sub-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:999px;padding:.38rem .62rem;background:#f1f5f9;color:#526476;font-size:.75rem;font-weight:700}.sub-chip.change{background:#fff4d6;color:#875c00}.sub-card-grid{display:grid;grid-template-columns:1fr 1fr;gap:.7rem;margin-top:1rem}.sub-data{border-radius:13px;background:#f7fafc;padding:.7rem}.sub-data-label{font-size:.68rem;text-transform:uppercase;letter-spacing:.07em;color:#8291a3;font-weight:750}.sub-data-value{color:#26384d;font-weight:700;margin-top:.12rem}.sub-card-actions{display:flex;gap:.5rem;border-top:1px solid #edf2f7;padding:.85rem 1.2rem}.sub-card-actions .btn{border-radius:10px;font-weight:650}.sub-empty{grid-column:1/-1;text-align:center;padding:5rem 1rem;border:1px dashed #bdd0dc;border-radius:22px;background:rgba(255,255,255,.72)}
.sub-section-title{font-weight:800;color:var(--ink);letter-spacing:-.02em}.modal-content{border:0;border-radius:22px;overflow:hidden;box-shadow:0 28px 80px rgba(15,37,61,.22)}#editModal .modal-header{background:linear-gradient(120deg,#0a3557,#0d7191);color:#fff;padding:1.25rem 1.5rem}#editModal .btn-close{filter:invert(1)}#editModal .modal-body{background:#f7fafc;padding:1.5rem}#editModal .form-control,#editModal .form-select{border-radius:11px;border-color:#d8e3eb}#editModal .modal-footer{background:#fff}
.sub-table-shell{border:1px solid var(--line);border-radius:18px;background:#fff;box-shadow:0 10px 30px rgba(30,64,99,.07);overflow:hidden}.sub-table{margin:0;min-width:1180px}.sub-table thead th{position:sticky;top:0;z-index:2;border:0;border-bottom:1px solid var(--line);background:#f3f7fa;color:#637589;padding:.8rem .9rem;font-size:.68rem;letter-spacing:.07em;text-transform:uppercase;white-space:nowrap}.sub-table tbody td{border-color:#edf2f6;padding:.8rem .9rem;vertical-align:middle;color:#26384d}.sub-table tbody tr{transition:background .15s ease}.sub-table tbody tr:hover{background:#f5fbfe}.sub-table tbody tr.is-blocked{box-shadow:inset 4px 0 #dc3545}.sub-table tbody tr.is-ending{box-shadow:inset 4px 0 #e8a317}.sub-table-primary{font-weight:800;color:var(--ink)}.sub-table-secondary{font-size:.75rem;color:var(--muted);margin-top:.15rem}.sub-product-lines{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.3rem}.sub-product-line{max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-radius:6px;background:#edf4f8;color:#526779;padding:.18rem .38rem;font-size:.67rem}.sub-table-actions{display:flex;justify-content:flex-end;gap:.35rem;white-space:nowrap}.sub-table-actions .btn{border-radius:8px}.sub-table-empty{text-align:center!important;padding:4rem 1rem!important}.sub-sort-note{display:inline-flex;align-items:center;gap:.3rem;color:#8191a1;font-size:.7rem}
.sub-view-tabs{display:flex;gap:.4rem;padding:.35rem;border:1px solid var(--line);border-radius:15px;background:rgba(255,255,255,.85);width:max-content;max-width:100%;box-shadow:0 8px 24px rgba(30,64,99,.06)}.sub-view-tabs .nav-link{border:0;border-radius:11px;color:#526779;font-weight:750;padding:.65rem 1rem}.sub-view-tabs .nav-link.active{background:var(--ink);color:#fff;box-shadow:0 6px 15px rgba(20,38,59,.18)}
.billing-calendar-shell{border:1px solid var(--line);border-radius:18px;background:#fff;box-shadow:0 10px 30px rgba(30,64,99,.07);padding:.85rem}.billing-calendar-toolbar{position:sticky;top:0;z-index:4;border:1px solid #dce7ed;border-radius:14px;background:rgba(255,255,255,.96);padding:.65rem;box-shadow:0 6px 18px rgba(30,64,99,.06);backdrop-filter:blur(10px)}.billing-calendar-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.65rem}.billing-month{border:1px solid #dce6ed;border-radius:13px;overflow:hidden;background:#fbfdfe}.billing-month-header{display:flex;justify-content:space-between;align-items:center;padding:.55rem .7rem;background:#edf5f8;color:#23465d;font-size:.84rem;font-weight:800}.billing-event{display:block;border-bottom:1px solid #e8eff3;padding:.48rem .6rem;color:inherit;text-decoration:none}.billing-event:last-child{border-bottom:0}.billing-event:hover{background:#f0f8fb;color:inherit}.billing-event-date{display:grid;place-items:center;flex:0 0 auto;width:30px;height:30px;border-radius:8px;background:#fff;border:1px solid #d4e1e8;color:#175e81;font-size:.8rem;font-weight:850}.billing-event.overdue .billing-event-date{background:#fff0f0;border-color:#f3c5c5;color:#b4232c}.billing-event.blocked .billing-event-date{background:#fff6dd;border-color:#ead493;color:#805b00}.billing-event-meta{font-size:.66rem;color:#6e8190}.billing-calendar-empty{grid-column:1/-1;text-align:center;padding:3rem;color:#718493}.calendar-legend{display:flex;flex-wrap:wrap;gap:.7rem;font-size:.72rem;color:#667b8b}.calendar-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:.25rem;background:#1686aa}.calendar-dot.overdue{background:#dc3545}.calendar-dot.blocked{background:#d29b00}.generated-order-row{border:1px solid #dce7ed;border-radius:10px;padding:.55rem .7rem;background:#f8fbfc}
@media(max-width:767px){.sub-shell{margin:-1rem;padding:1rem}.sub-hero{padding:1.4rem}.subscription-grid{grid-template-columns:1fr}.sub-toolbar>.row{gap:.6rem}.sub-card-grid{grid-template-columns:1fr}}
</style>
<div class="sub-shell">
<div class="sub-hero mb-4">
<div class="position-relative" style="z-index:1">
<div class="sub-kicker mb-2">Recurring revenue cockpit</div>
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
<div><h1 class="sub-title mb-2">Abonnementer, helt under kontrol.</h1><p class="sub-hero-copy mb-0">Se økonomi, fakturerytme og ændringer i ét levende overblik — fra første bankdag til sidste godkendelse.</p></div>
<div class="d-flex flex-wrap gap-2">
<a href="/subscriptions/simply-imports" class="btn btn-outline-light"><i class="bi bi-cloud-arrow-down me-2"></i>Importcenter</a>
<button type="button" class="btn btn-outline-light" onclick="openSubscriptionView('calendar')"><i class="bi bi-calendar3 me-2"></i>Ordrekalender</button>
<button class="btn btn-light" onclick="document.getElementById('subscriptionSearch').focus()"><i class="bi bi-search me-2"></i>Find abonnement</button>
</div>
</div>
<div class="col-auto d-flex gap-2 align-items-start">
<a href="/subscriptions/simply-imports" class="btn btn-outline-primary">
<i class="bi bi-cloud-arrow-down me-1"></i>Simply Import Oversigt
</a>
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
<option value="all" selected>Alle statuser</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
<option value="cancelled">Opsagt</option>
<option value="draft">Kladde</option>
</select>
</div>
</div>
<ul class="nav sub-view-tabs mb-4" id="subscriptionViewTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" id="subscriptions-overview-tab" data-bs-toggle="tab" data-bs-target="#subscriptionsOverviewPane" type="button" role="tab"><i class="bi bi-table me-2"></i>Abonnementer</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="subscriptions-calendar-tab" data-bs-toggle="tab" data-bs-target="#subscriptionsCalendarPane" type="button" role="tab"><i class="bi bi-calendar3 me-2"></i>Ordrekalender</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="subscriptionsOverviewPane" role="tabpanel">
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Aktive Abonnementer</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Total Pris (aktive)</p>
<h3 class="mb-0" id="totalAmount">-</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Gns. Pris</p>
<h3 class="mb-0" id="avgAmount">-</h3>
</div>
</div>
</div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-lightning-charge"></i></div><div><div class="sub-metric-label">Aktive aftaler</div><div class="sub-metric-value" id="activeCount"></div></div></div></div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-graph-up-arrow"></i></div><div><div class="sub-metric-label">Månedlig værdi</div><div class="sub-metric-value" id="totalAmount"></div></div></div></div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-calendar2-check"></i></div><div><div class="sub-metric-label">Faktura ≤ 7 dage</div><div class="sub-metric-value" id="dueSoonCount"></div></div></div></div>
<div class="col-6 col-xl-3"><div class="sub-metric d-flex align-items-center gap-3"><div class="sub-metric-icon"><i class="bi bi-hourglass-split"></i></div><div><div class="sub-metric-label">Åbne ændringer</div><div class="sub-metric-value" id="changeCount"></div></div></div></div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
<div class="sub-toolbar mb-3">
<div class="row align-items-center">
<div class="col-lg-4"><div class="input-group"><span class="input-group-text border-0 bg-transparent"><i class="bi bi-search text-muted"></i></span><input class="form-control sub-search" id="subscriptionSearch" placeholder="Søg kunde, produkt, sag eller nummer…" autocomplete="off"></div></div>
<div class="col-lg-8"><div class="sub-filter-pills justify-content-lg-end" id="subscriptionFilterPills">
<button class="sub-filter-pill active" data-status="all">Alle</button><button class="sub-filter-pill" data-status="active">Aktive</button><button class="sub-filter-pill" data-status="scheduled">Planlagte</button><button class="sub-filter-pill" data-status="paused">Pausede</button><button class="sub-filter-pill" data-status="terminating">Under opsigelse</button><button class="sub-filter-pill" data-status="blocked">Kræver handling</button><button class="sub-filter-pill" data-status="ended">Afsluttede</button>
</div></div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th>Abonnement</th>
<th>Kunde</th>
<th>Sag</th>
<th>Produkt</th>
<th>Interval</th>
<th>Pris</th>
<th>Start</th>
<th>Status</th>
<th width="150">Handlinger</th>
</tr>
</thead>
<tbody id="subscriptionsBody">
<tr>
<td colspan="9" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
</td>
</tr>
</tbody>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-2 mb-3"><div><div class="sub-kicker text-primary">Portfolio · skrivebeskyttet overblik</div><h2 class="h4 sub-section-title mb-0" id="subscriptionsTitle">Alle abonnementer</h2><div class="small text-muted mt-1"><i class="bi bi-shield-lock me-1"></i>Ændringer og opsigelser bestilles på abonnementfanen i hovedsagen.</div></div><div class="small text-muted" id="subscriptionResultCount"></div></div>
<div class="sub-table-shell table-responsive">
<table class="table sub-table align-middle">
<thead><tr><th>Abonnement</th><th>Kunde / hovedsag</th><th>Produkt</th><th>Status</th><th>Periode</th><th>Fakturering</th><th>Næste faktura</th><th class="text-end">Pris</th><th>Ændring</th><th class="text-end">Hovedsag</th></tr></thead>
<tbody id="subscriptionsGrid"><tr><td colspan="10" class="sub-table-empty"><span class="spinner-border text-primary mb-3"></span><div>Indlæser abonnementer…</div></td></tr></tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="subscriptionsCalendarPane" role="tabpanel">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-2 mb-3"><div><div class="sub-kicker text-primary">Fakturering og ordrekladder</div><h2 class="h4 sub-section-title mb-1">Ordrekalender</h2><div class="small text-muted">Forventede ordrekladder beregnet med præcis samme regler som fakturajobbet.</div></div><div class="calendar-legend"><span><i class="calendar-dot"></i>Planlagt</span><span><i class="calendar-dot overdue"></i>Forfalden</span><span><i class="calendar-dot blocked"></i>Blokeret</span></div></div>
<div class="billing-calendar-shell">
<div class="billing-calendar-toolbar mb-3"><div class="row g-2 align-items-center">
<div class="col-lg-4"><div class="input-group input-group-sm"><span class="input-group-text bg-white"><i class="bi bi-search"></i></span><input class="form-control" id="billingCalendarSearch" placeholder="Søg kunde, abonnement, produkt eller sag…"></div></div>
<div class="col-6 col-lg-2"><select class="form-select form-select-sm" id="billingCalendarState"><option value="all">Alle statusser</option><option value="planned">Planlagte</option><option value="overdue">Forfaldne</option><option value="blocked">Blokerede</option></select></div>
<div class="col-6 col-lg-2"><select class="form-select form-select-sm" id="billingCalendarCustomer"><option value="all">Alle kunder</option></select></div>
<div class="col-6 col-lg-1"><select class="form-select form-select-sm" id="billingCalendarMonths" title="Tidshorisont"><option value="6">6 mdr.</option><option value="12" selected>12 mdr.</option><option value="18">18 mdr.</option><option value="24">24 mdr.</option></select></div>
<div class="col-6 col-lg-2"><select class="form-select form-select-sm" id="billingCalendarLimit" title="Maksimalt antal viste ordrer"><option value="100">Vis 100</option><option value="250" selected>Vis 250</option><option value="500">Vis 500</option><option value="all">Vis alle</option></select></div>
<div class="col-12 col-lg-1 d-grid"><button class="btn btn-sm btn-outline-primary" onclick="loadBillingCalendar()" title="Genberegn"><i class="bi bi-arrow-clockwise"></i></button></div>
</div></div>
<div class="row g-2 mb-3" id="billingCalendarStats"></div><div class="billing-calendar-grid" id="billingCalendarGrid"><div class="billing-calendar-empty text-muted"><i class="bi bi-calendar3 fs-2 d-block mb-2"></i>Åbn kalenderen for at beregne forecast.</div></div><details class="mt-3"><summary class="fw-semibold text-primary">Senest genererede ordrekladder</summary><div class="d-grid gap-2 mt-2" id="generatedOrdersList"></div></details>
</div>
</div>
</div>
</div>
@ -88,7 +87,7 @@
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger Abonnement</h5>
<div><div class="small text-white-50 text-uppercase fw-bold" style="letter-spacing:.1em">Kontrolleret ændring</div><h5 class="modal-title">Foreslå abonnementsændring</h5></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@ -103,14 +102,14 @@
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Pris (DKK)</label>
<input type="number" class="form-control" id="editPrice" step="0.01" min="0">
<label class="form-label">Ny total <span class="text-muted small">(beregnes fra linjer)</span></label>
<input type="number" class="form-control bg-light" id="editPrice" step="0.01" min="0" readonly>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Billing interval</label>
<select class="form-select" id="editInterval">
@ -122,10 +121,18 @@
</select>
</div>
</div>
<div class="col-md-6">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Billing dag (1-31)</label>
<input type="number" class="form-control" id="editBillingDay" min="1" max="31">
<label class="form-label">Faktureringsregel</label>
<select class="form-select" id="editScheduleType">
<option value="fixed_day">Fast dag</option><option value="first_business_day">Første bankdag</option><option value="last_business_day">Sidste bankdag</option><option value="interval_anchor">Fast interval</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Dag (128)</label>
<input type="number" class="form-control" id="editBillingDay" min="1" max="28">
</div>
</div>
</div>
@ -149,13 +156,13 @@
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Periode start <i class="bi bi-info-circle" title="Startdato for nuværende faktureringsperiode"></i></label>
<input type="date" class="form-control" id="editPeriodStart">
<input type="date" class="form-control bg-light" id="editPeriodStart" readonly>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Næste faktura dato <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
<input type="date" class="form-control" id="editNextInvoiceDate">
<input type="date" class="form-control bg-light" id="editNextInvoiceDate" readonly>
</div>
</div>
</div>
@ -172,9 +179,13 @@
<label class="form-label">Status</label>
<select class="form-select" id="editStatus">
<option value="draft">Kladde</option>
<option value="scheduled">Planlagt</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
<option value="terminating">Under opsigelse</option>
<option value="cancelled">Opsagt</option>
<option value="expired">Udløbet</option>
<option value="blocked">Blokeret</option>
</select>
</div>
</div>
@ -185,6 +196,10 @@
<textarea class="form-control" id="editNotes" rows="3"></textarea>
</div>
<div class="card border-0 shadow-sm mb-3" style="border-radius:16px"><div class="card-body">
<div class="d-flex gap-3 align-items-start"><div class="sub-metric-icon flex-shrink-0"><i class="bi bi-shield-check"></i></div><div class="flex-grow-1"><h6 class="mb-1">Godkendelsespakke</h6><p class="small text-muted mb-3">Ændringen oprettes som undersag og skal godkendes af en anden bruger.</p><div class="row g-3"><div class="col-md-8"><label class="form-label">Begrundelse *</label><textarea class="form-control" id="editChangeReason" rows="2" placeholder="Hvad ændres — og hvorfor?"></textarea></div><div class="col-md-4"><label class="form-label">Ikrafttrædelse *</label><input type="date" class="form-control" id="editEffectiveDate"></div></div></div></div>
</div></div>
<hr class="my-4">
<!-- Line Items Section -->
@ -223,7 +238,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">Gem ændringer</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()"><i class="bi bi-send-check me-1"></i>Opret ændringsundersag</button>
</div>
</div>
</div>
@ -263,6 +278,9 @@
<script>
let currentSubscriptions = [];
let currentStagingCustomerKey = null;
let currentSubscriptionStatus = 'all';
let currentBillingCalendarData = null;
let billingCalendarLoaded = false;
function stagingStatusBadge(status) {
const badges = {
@ -513,93 +531,201 @@ async function approveSelectedStagingRows() {
async function loadSubscriptions() {
try {
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
const stats = await fetch(`/api/v1/sag-subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
const subscriptions = await fetch(`/api/v1/sag-subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
const response = await fetch('/api/v1/sag-subscriptions?status=all');
const subscriptions = await response.json();
if (!response.ok) throw new Error(subscriptions.detail || 'Kunne ikke hente abonnementer');
currentSubscriptions = subscriptions;
renderSubscriptions(subscriptions);
const title = document.getElementById('subscriptionsTitle');
if (title) {
const labelMap = {
all: 'Alle abonnementer',
active: 'Aktive abonnementer',
paused: 'Pausede abonnementer',
cancelled: 'Opsagte abonnementer',
draft: 'Kladder'
};
title.textContent = labelMap[status] || 'Abonnementer';
}
updateSubscriptionMetrics(subscriptions);
applySubscriptionFilters();
} catch (e) {
console.error('Error loading subscriptions:', e);
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 indlæsning</p>
</td></tr>
`;
document.getElementById('subscriptionsGrid').innerHTML = `<tr><td colspan="10" class="sub-table-empty text-danger"><i class="bi bi-exclamation-triangle fs-1 d-block mb-3"></i><strong>Abonnementerne kunne ikke indlæses</strong><div class="small mt-1">${escapeHtml(e.message)}</div><button class="btn btn-outline-danger mt-3" onclick="loadSubscriptions()">Prøv igen</button></td></tr>`;
}
}
async function loadBillingCalendar() {
const grid = document.getElementById('billingCalendarGrid');
const months = document.getElementById('billingCalendarMonths')?.value || 12;
if (!grid) return;
grid.innerHTML = '<div class="billing-calendar-empty"><span class="spinner-border text-primary mb-2"></span><div>Beregner ordrekalender…</div></div>';
try {
const response = await fetch(`/api/v1/subscription-billing-forecast?months=${encodeURIComponent(months)}`);
const responseText = await response.text();
let data = {};
try {
data = responseText ? JSON.parse(responseText) : {};
} catch (_error) {
data = {detail: responseText || `HTTP ${response.status}`};
}
if (!response.ok) {
const detail = Array.isArray(data.detail)
? data.detail.map(item => item.msg || JSON.stringify(item)).join(' · ')
: (typeof data.detail === 'object' ? JSON.stringify(data.detail) : data.detail);
throw new Error(detail || 'Kunne ikke beregne ordrekalender');
}
currentBillingCalendarData = data;
billingCalendarLoaded = true;
populateBillingCalendarCustomers(data.forecast || []);
applyBillingCalendarFilters();
} catch (error) {
grid.innerHTML = `<div class="billing-calendar-empty text-danger"><i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>${escapeHtml(error.message)}<br><button class="btn btn-sm btn-outline-danger mt-2" onclick="loadBillingCalendar()">Prøv igen</button></div>`;
}
}
function populateBillingCalendarCustomers(forecast) {
const select = document.getElementById('billingCalendarCustomer');
if (!select) return;
const selected = select.value;
const customers = new Map();
forecast.forEach(item => customers.set(String(item.customer_id), item.customer_name || `Kunde #${item.customer_id}`));
select.innerHTML = '<option value="all">Alle kunder</option>' + Array.from(customers.entries())
.sort((a, b) => a[1].localeCompare(b[1], 'da'))
.map(([id, name]) => `<option value="${escapeHtml(id)}">${escapeHtml(name)}</option>`).join('');
if ([...select.options].some(option => option.value === selected)) select.value = selected;
}
function applyBillingCalendarFilters() {
if (!currentBillingCalendarData) return;
const query = (document.getElementById('billingCalendarSearch')?.value || '').trim().toLocaleLowerCase('da-DK');
const state = document.getElementById('billingCalendarState')?.value || 'all';
const customer = document.getElementById('billingCalendarCustomer')?.value || 'all';
const forecast = (currentBillingCalendarData.forecast || []).filter(item => {
const subscriptions = item.subscriptions || [];
const haystack = [item.customer_name, item.invoice_merge_key, ...subscriptions.flatMap(sub => [sub.subscription_number, sub.product_name, sub.sag_title, sub.sag_id])]
.filter(Boolean).join(' ').toLocaleLowerCase('da-DK');
return (state === 'all' || item.state === state)
&& (customer === 'all' || String(item.customer_id) === customer)
&& (!query || haystack.includes(query));
});
renderBillingCalendar({...currentBillingCalendarData, forecast});
}
function openSubscriptionView(view) {
const tabId = view === 'calendar' ? 'subscriptions-calendar-tab' : 'subscriptions-overview-tab';
const tab = document.getElementById(tabId);
if (tab && window.bootstrap?.Tab) bootstrap.Tab.getOrCreateInstance(tab).show();
tab?.scrollIntoView({behavior:'smooth', block:'start'});
}
function renderBillingCalendar(data) {
const forecast = data.forecast || [];
const limitValue = document.getElementById('billingCalendarLimit')?.value || '250';
const visibleForecast = limitValue === 'all' ? forecast : forecast.slice(0, Number(limitValue));
const months = new Map();
visibleForecast.forEach(item => {
const key = (item.display_date || item.invoice_date).slice(0, 7);
if (!months.has(key)) months.set(key, []);
months.get(key).push(item);
});
const grid = document.getElementById('billingCalendarGrid');
const stats = document.getElementById('billingCalendarStats');
const total = forecast.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const overdue = forecast.filter(item => item.state === 'overdue');
const blocked = forecast.filter(item => item.state === 'blocked');
if (stats) stats.innerHTML = `
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Viste ordrer</div><div class="h5 fw-bold mb-0">${visibleForecast.length}${visibleForecast.length < forecast.length ? ` <span class="fs-6 text-muted">af ${forecast.length}</span>` : ''}</div></div></div>
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Forventet værdi</div><div class="h5 fw-bold mb-0">${formatCurrency(total)}</div></div></div>
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Forfaldne</div><div class="h5 fw-bold mb-0 text-danger">${overdue.length}</div></div></div>
<div class="col-6 col-lg-3"><div class="sub-data h-100 py-2"><div class="sub-data-label">Blokerede</div><div class="h5 fw-bold mb-0 text-warning">${blocked.length}</div></div></div>`;
if (grid) grid.innerHTML = months.size ? Array.from(months.entries()).map(([key, items]) => {
const monthDate = new Date(`${key}-01T12:00:00`);
const monthLabel = monthDate.toLocaleDateString('da-DK', {month:'long', year:'numeric'});
const monthTotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
return `<div class="billing-month"><div class="billing-month-header"><span class="text-capitalize">${escapeHtml(monthLabel)}</span><span>${formatCurrency(monthTotal)}</span></div>${items.map(item => {
const day = Number((item.display_date || item.invoice_date).slice(8,10));
const firstSub = item.subscriptions?.[0];
const href = firstSub?.sag_id ? `/sag/${firstSub.sag_id}/v3?tab=subscription` : '#';
const coverage = firstSub ? `${formatDate(firstSub.coverage_start)}${formatDate(firstSub.coverage_end)}` : '';
return `<a class="billing-event ${item.state}" href="${href}"><div class="d-flex gap-2 align-items-start"><div class="billing-event-date">${day}</div><div class="min-w-0 flex-grow-1"><div class="d-flex justify-content-between gap-2"><strong class="text-truncate">${escapeHtml(item.customer_name)}</strong><strong>${formatCurrency(item.amount)}</strong></div><div class="billing-event-meta">${item.subscription_count} abonnement${item.subscription_count === 1 ? '' : 'er'} · ${escapeHtml(coverage)}</div>${item.state === 'overdue' ? '<div class="small text-danger fw-semibold">Skulle allerede være genereret</div>' : item.state === 'blocked' ? '<div class="small text-warning fw-semibold">Blokeret — kræver handling</div>' : ''}</div></div></a>`;
}).join('')}</div>`;
}).join('') : '<div class="billing-calendar-empty"><i class="bi bi-calendar2-check fs-2 d-block mb-2"></i>Ingen planlagte ordrekladder i perioden.</div>';
const generatedList = document.getElementById('generatedOrdersList');
if (generatedList) generatedList.innerHTML = (data.generated || []).length ? data.generated.map(order => {
const lines = Array.isArray(order.lines_json) ? order.lines_json : [];
const amount = lines.reduce((sum, line) => sum + Number(line.totalNetAmount || 0), 0);
return `<div class="generated-order-row d-flex flex-wrap justify-content-between gap-2"><div><strong>Ordrekladde #${order.ordre_draft_id || '—'}</strong><div class="small text-muted">Abonnement #${order.subscription_id} · periode ${formatDate(order.period_start)} · genereret ${formatDate(order.created_at)}</div></div><div class="text-end"><strong>${formatCurrency(amount)}</strong><div class="small text-muted">${escapeHtml(order.sync_status || 'oprettet')}</div></div></div>`;
}).join('') : '<div class="text-muted small py-2">Ingen genererede ordrekladder i den viste historik.</div>';
}
function monthlyValue(subscription) {
const value = Number(subscription.price || 0);
return {daily: value * 30.4375, biweekly: value * 2.171, monthly: value, quarterly: value / 3, yearly: value / 12}[subscription.billing_interval] || value;
}
function updateSubscriptionMetrics(subscriptions) {
const active = subscriptions.filter(sub => sub.status === 'active');
const now = new Date();
const inSevenDays = new Date(now); inSevenDays.setDate(inSevenDays.getDate() + 7);
const dueSoon = active.filter(sub => sub.next_invoice_date && new Date(sub.next_invoice_date) >= new Date(now.toDateString()) && new Date(sub.next_invoice_date) <= inSevenDays);
document.getElementById('activeCount').textContent = active.length.toLocaleString('da-DK');
document.getElementById('totalAmount').textContent = compactCurrency(active.reduce((sum, sub) => sum + monthlyValue(sub), 0));
document.getElementById('dueSoonCount').textContent = dueSoon.length.toLocaleString('da-DK');
document.getElementById('changeCount').textContent = subscriptions.filter(sub => sub.change_status).length.toLocaleString('da-DK');
}
function applySubscriptionFilters() {
const query = (document.getElementById('subscriptionSearch')?.value || '').trim().toLocaleLowerCase('da-DK');
const filtered = currentSubscriptions.filter(sub => {
const matchesStatus = currentSubscriptionStatus === 'all'
|| (currentSubscriptionStatus === 'ended' ? ['cancelled','expired'].includes(sub.status) : sub.status === currentSubscriptionStatus);
const productLines = (sub.product_lines || []).flatMap(line => [line.product_name, line.sku_internal, line.er_number, line.ean, line.supplier_sku, line.description]);
const haystack = [sub.subscription_number, sub.customer_name, sub.sag_title, sub.product_name, sub.product_search_text, sub.status, ...productLines].filter(Boolean).join(' ').toLocaleLowerCase('da-DK');
return matchesStatus && (!query || haystack.includes(query));
});
renderSubscriptions(filtered);
const labels = {all:'Alle abonnementer',active:'Aktive abonnementer',scheduled:'Planlagte abonnementer',paused:'Pausede abonnementer',terminating:'Under opsigelse',blocked:'Kræver handling',ended:'Afsluttede abonnementer'};
document.getElementById('subscriptionsTitle').textContent = labels[currentSubscriptionStatus] || 'Abonnementer';
document.getElementById('subscriptionResultCount').textContent = `${filtered.length} af ${currentSubscriptions.length}`;
}
function renderSubscriptions(subscriptions) {
const tbody = document.getElementById('subscriptionsBody');
const grid = document.getElementById('subscriptionsGrid');
if (!subscriptions || subscriptions.length === 0) {
tbody.innerHTML = `
<tr><td colspan="9" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aktive abonnementer</p>
</td></tr>
`;
grid.innerHTML = `<tr><td colspan="10" class="sub-table-empty"><i class="bi bi-search fs-1 text-primary d-block mb-3"></i><h3 class="h5">Ingen abonnementer matcher</h3><p class="text-muted mb-0">Prøv en anden status eller ryd søgningen.</p></td></tr>`;
return;
}
tbody.innerHTML = subscriptions.map(sub => {
grid.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}/v3">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
// Show product name with item count if available
let productDisplay = sub.product_name || '-';
if (sub.line_items && sub.line_items.length > 0) {
productDisplay = `${sub.product_name} <span class="badge bg-light text-dark">${sub.line_items.length} varer</span>`;
}
const canEdit = sub.status !== 'cancelled';
const canCancel = sub.status === 'active' || sub.status === 'paused';
const actions = `
<div class="btn-group btn-group-sm">
${canEdit ? `<button class="btn btn-outline-primary" onclick="openEditModal(${sub.id})" title="Rediger">
<i class="bi bi-pencil"></i>
</button>` : ''}
${canCancel ? `<button class="btn btn-outline-danger" onclick="openCancelModal(${sub.id})" title="Opsig">
<i class="bi bi-x-circle"></i>
</button>` : ''}
</div>
`;
const sagLink = sub.sag_id ? `/sag/${sub.sag_id}/v3?tab=subscription` : '#';
const subNumber = escapeHtml(sub.subscription_number || `#${sub.id}`);
const schedule = billingScheduleLabel(sub);
const nextInvoice = sub.next_invoice_date ? formatDate(sub.next_invoice_date) : 'Ikke planlagt';
const rowClass = sub.billing_blocked || sub.status === 'blocked' ? 'is-blocked' : ['terminating','cancelled','expired'].includes(sub.status) ? 'is-ending' : '';
const period = [sub.period_start || sub.start_date, sub.end_date].filter(Boolean).map(formatDate);
const productLines = (sub.product_lines || []).slice(0, 3);
const remainingLines = Math.max(0, Number(sub.item_count || 0) - productLines.length);
return `
<tr>
<td><strong>${subNumber}</strong></td>
<td>${sub.customer_name || '-'}</td>
<td>${sagLink}</td>
<td>${productDisplay}</td>
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
<td>${formatCurrency(sub.price || 0)}</td>
<td>${formatDate(sub.start_date)}</td>
<td>${statusBadge}</td>
<td>${actions}</td>
<tr class="${rowClass}">
<td><a href="${sagLink}" class="sub-table-primary text-decoration-none">${subNumber}</a><div class="sub-table-secondary">ID ${sub.id}</div></td>
<td><div class="sub-table-primary">${escapeHtml(sub.customer_name || 'Ukendt kunde')}</div><a href="${sagLink}" class="sub-table-secondary text-decoration-none">${escapeHtml(sub.sag_title || 'Sag #' + sub.sag_id)}</a></td>
<td><div class="sub-table-primary">${escapeHtml(sub.product_name || 'Abonnement')}</div><div class="sub-product-lines">${productLines.map(line => `<span class="sub-product-line" title="${escapeHtml(line.product_name || line.description || '')}">${escapeHtml(line.product_name || line.description || 'Varelinje')}</span>`).join('')}${remainingLines ? `<span class="sub-product-line">+${remainingLines}</span>` : ''}</div></td>
<td>${statusBadge}${sub.billing_blocked ? `<div class="sub-table-secondary text-danger" title="${escapeHtml(sub.billing_block_reason || '')}"><i class="bi bi-exclamation-octagon me-1"></i>Blokeret</div>` : ''}</td>
<td><div>${escapeHtml(period[0] || '')}</div><div class="sub-table-secondary">${period[1] ? `til ${escapeHtml(period[1])}` : 'Ingen slutdato'}</div></td>
<td><div class="sub-table-primary">${escapeHtml(intervalLabel)}</div><div class="sub-table-secondary">${escapeHtml(schedule)}</div></td>
<td><div class="sub-table-primary">${escapeHtml(nextInvoice)}</div><div class="sub-table-secondary">${sub.billing_day ? `Dag ${sub.billing_day}` : ''}</div></td>
<td class="text-end"><div class="sub-table-primary">${formatCurrency(sub.price || 0)}</div><div class="sub-table-secondary">${formatCurrency(monthlyValue(sub))}/md.</div></td>
<td>${sub.change_status ? `<a href="/sag/${sub.change_sag_id}/v3" class="sub-chip change text-decoration-none"><i class="bi bi-hourglass-split"></i>${changeStatusLabel(sub.change_status)}</a>` : '<span class="text-muted"></span>'}</td>
<td><div class="sub-table-actions"><a class="btn btn-sm btn-primary" href="${sagLink}" title="Åbn abonnementet på hovedsagen"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag</a></div></td>
</tr>
`;
}).join('');
}
function billingScheduleLabel(sub) {
if (sub.billing_interval === 'daily' || sub.billing_interval === 'biweekly' || sub.billing_schedule_type === 'interval_anchor') return 'Fast interval';
if (sub.billing_schedule_type === 'first_business_day') return 'Første bankdag';
if (sub.billing_schedule_type === 'last_business_day') return 'Sidste bankdag';
return `Dag ${sub.billing_day || 1}`;
}
function changeStatusLabel(status) {
return {draft:'Ændringskladde',pending:'Afventer godkendelse',approved_scheduled:'Planlagt ændring',applying:'Anvendes nu',partially_applied:'Delvist gennemført',failed:'Kræver handling',cancellation_pending:'Annullering afventer'}[status] || 'Åben ændring';
}
async function openEditModal(subId) {
try {
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`);
@ -614,6 +740,7 @@ async function openEditModal(subId) {
document.getElementById('editProductName').value = sub.product_name || '';
document.getElementById('editPrice').value = sub.price || 0;
document.getElementById('editInterval').value = sub.billing_interval || 'monthly';
document.getElementById('editScheduleType').value = sub.billing_schedule_type || 'fixed_day';
document.getElementById('editBillingDay').value = sub.billing_day || 1;
document.getElementById('editStartDate').value = sub.start_date || '';
document.getElementById('editEndDate').value = sub.end_date || '';
@ -622,6 +749,9 @@ async function openEditModal(subId) {
document.getElementById('editNoticePeriod').value = sub.notice_period_days || 30;
document.getElementById('editStatus').value = sub.status || 'draft';
document.getElementById('editNotes').value = sub.notes || '';
document.getElementById('editChangeReason').value = '';
document.getElementById('editEffectiveDate').value = new Date().toISOString().slice(0, 10);
syncEditScheduleControls();
// Load line items
renderLineItems(sub.line_items || []);
@ -727,6 +857,7 @@ function calculateTotal() {
async function saveEdit() {
try {
const subId = document.getElementById('editSubId').value;
const currentSub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
// Validate line items
const validLineItems = lineItemsData.filter(item => {
@ -741,23 +872,29 @@ async function saveEdit() {
const payload = {
product_name: document.getElementById('editProductName').value,
price: parseFloat(document.getElementById('editPrice').value),
billing_interval: document.getElementById('editInterval').value,
billing_schedule_type: document.getElementById('editScheduleType').value,
billing_day: parseInt(document.getElementById('editBillingDay').value),
start_date: document.getElementById('editStartDate').value || null,
end_date: document.getElementById('editEndDate').value || null,
period_start: document.getElementById('editPeriodStart').value || null,
next_invoice_date: document.getElementById('editNextInvoiceDate').value || null,
notice_period_days: parseInt(document.getElementById('editNoticePeriod').value),
status: document.getElementById('editStatus').value,
notes: document.getElementById('editNotes').value,
line_items: validLineItems
};
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`, {
method: 'PATCH',
const reason = document.getElementById('editChangeReason').value.trim();
const effectiveDate = document.getElementById('editEffectiveDate').value;
if (!reason || !effectiveDate) return alert('Angiv begrundelse og ikrafttrædelsesdato');
const response = await fetch('/api/v1/sag-subscriptions/change-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
body: JSON.stringify({
main_sag_id: currentSub.sag_id,
reason,
effective_date: effectiveDate,
proposals: [{subscription_id: Number(subId), changes: payload}]
})
});
if (!response.ok) {
@ -765,9 +902,9 @@ async function saveEdit() {
throw new Error(error.detail || 'Failed to update');
}
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadSubscriptions();
alert('✅ Abonnement opdateret');
window.location.href = result.case_url;
} catch (e) {
alert('❌ Fejl ved opdatering: ' + e.message);
}
@ -797,20 +934,27 @@ async function confirmCancel() {
try {
const subId = document.getElementById('cancelSubId').value;
const reason = document.getElementById('cancelReason').value;
const response = await fetch(`/api/v1/subscriptions/${subId}/cancel`, {
const sub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
const noticeDays = Number(sub.notice_period_days || 30);
const effective = new Date();
effective.setDate(effective.getDate() + noticeDays);
const effectiveDate = effective.toISOString().slice(0, 10);
const response = await fetch('/api/v1/sag-subscriptions/change-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, user_id: 1 })
body: JSON.stringify({
main_sag_id: sub.sag_id,
reason: reason || 'Opsigelse',
effective_date: effectiveDate,
proposals: [{subscription_id: Number(subId), changes: {status: 'terminating', end_date: effectiveDate}}]
})
});
if (!response.ok) throw new Error('Failed to cancel');
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
loadSubscriptions();
alert(`✅ Abonnement opsagt\nSlutdato: ${new Date(result.end_date).toLocaleDateString('da-DK')}\nSag oprettet: #${result.cancellation_case_id}`);
window.location.href = result.case_url;
} catch (e) {
alert('❌ Fejl ved opsigelse: ' + e.message);
}
@ -829,14 +973,32 @@ function formatInterval(interval) {
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'paused': '<span class="badge bg-warning">Pauset</span>',
'cancelled': '<span class="badge bg-secondary">Opsagt</span>',
'draft': '<span class="badge bg-light text-dark">Kladde</span>'
'active': '<span class="badge rounded-pill bg-success-subtle text-success border border-success-subtle">● Aktiv</span>',
'scheduled': '<span class="badge rounded-pill bg-info-subtle text-info-emphasis">Planlagt</span>',
'paused': '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Pauset</span>',
'terminating': '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Under opsigelse</span>',
'cancelled': '<span class="badge rounded-pill bg-secondary-subtle text-secondary">Opsagt</span>',
'expired': '<span class="badge rounded-pill bg-secondary-subtle text-secondary">Udløbet</span>',
'blocked': '<span class="badge rounded-pill bg-danger-subtle text-danger">Kræver handling</span>',
'draft': '<span class="badge rounded-pill bg-light text-dark border">Kladde</span>'
};
return badges[status] || status || '-';
}
function compactCurrency(amount) {
return new Intl.NumberFormat('da-DK', {style:'currency',currency:'DKK',notation:'compact',maximumFractionDigits:1}).format(Number(amount || 0));
}
function syncEditScheduleControls() {
const interval = document.getElementById('editInterval')?.value;
const schedule = document.getElementById('editScheduleType');
const day = document.getElementById('editBillingDay');
const anchored = ['daily','biweekly'].includes(interval);
if (anchored) schedule.value = 'interval_anchor';
schedule.disabled = anchored;
day.disabled = anchored || schedule.value !== 'fixed_day';
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
@ -853,10 +1015,22 @@ function formatDate(dateStr) {
}
document.addEventListener('DOMContentLoaded', () => {
const filter = document.getElementById('subscriptionStatusFilter');
if (filter) {
filter.addEventListener('change', loadSubscriptions);
}
document.getElementById('subscriptionSearch')?.addEventListener('input', applySubscriptionFilters);
document.querySelectorAll('.sub-filter-pill').forEach(button => button.addEventListener('click', () => {
currentSubscriptionStatus = button.dataset.status;
document.querySelectorAll('.sub-filter-pill').forEach(item => item.classList.toggle('active', item === button));
applySubscriptionFilters();
}));
document.getElementById('editInterval')?.addEventListener('change', syncEditScheduleControls);
document.getElementById('editScheduleType')?.addEventListener('change', syncEditScheduleControls);
document.getElementById('billingCalendarMonths')?.addEventListener('change', loadBillingCalendar);
document.getElementById('billingCalendarSearch')?.addEventListener('input', applyBillingCalendarFilters);
document.getElementById('billingCalendarState')?.addEventListener('change', applyBillingCalendarFilters);
document.getElementById('billingCalendarCustomer')?.addEventListener('change', applyBillingCalendarFilters);
document.getElementById('billingCalendarLimit')?.addEventListener('change', applyBillingCalendarFilters);
document.getElementById('subscriptions-calendar-tab')?.addEventListener('shown.bs.tab', () => {
if (!billingCalendarLoaded) loadBillingCalendar();
});
loadSubscriptions();
});
</script>

View File

@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
header('X-BMC-Admin-Version: 8');
const MAX_LOGO_BYTES = 5242880;
const ALLOWED_LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
const DEFAULT_ADMIN_TOKEN_HASH = '57ea24fcc6bcfe492247be0051915deb2e735910a9c16fc58b22f273987fafb3';
function adminTokenHash(): string
{
return DEFAULT_ADMIN_TOKEN_HASH;
}
function requireAdminToken(): string
{
$expectedHash = adminTokenHash();
$provided = trim((string)($_SERVER['HTTP_X_WEBSITE_ADMIN_TOKEN'] ?? ''));
if ($expectedHash === '' || $provided === '' || !hash_equals($expectedHash, hash('sha256', $provided))) {
bmc_json_response(['error' => 'unauthorized', 'message' => 'Ugyldig admin-token.'], 401);
}
return $provided;
}
function adminDb(string $credential): PDO
{
try {
return bmc_db();
} catch (RuntimeException $e) {
if (!str_contains($e->getMessage(), 'environment variables are missing')) {
throw $e;
}
}
return new PDO(
'mysql:host=127.0.0.1;port=3306;dbname=bmcnetworks_26;charset=utf8mb4',
'bmc_26dcrhccr',
$credential,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
}
function body(): array
{
$decoded = json_decode(file_get_contents('php://input') ?: '{}', true);
if (!is_array($decoded)) {
bmc_json_response(['error' => 'invalid_json', 'message' => 'Ugyldig JSON.'], 400);
}
return $decoded;
}
function resourceConfig(string $resource): array
{
$configs = [
'customers' => [
'table' => 'customer_references',
'fields' => ['customer_name', 'logo_url', 'website_url', 'sort_order', 'is_active'],
'required' => ['customer_name'],
'visibility' => 'is_active',
'order' => 'sort_order ASC, customer_name ASC',
'select' => 'id, customer_name, logo_url, website_url, sort_order, is_active, source, updated_at',
],
'operations' => [
'table' => 'operations_status',
'fields' => ['title', 'severity', 'message', 'starts_at', 'ends_at', 'is_active'],
'required' => ['title', 'message'],
'visibility' => 'is_active',
'order' => 'updated_at DESC',
'select' => 'id, title, severity, message, starts_at, ends_at, is_active, source, updated_at',
],
'incidents' => [
'table' => 'operations_incidents',
'fields' => ['title', 'severity', 'message', 'starts_at', 'ends_at', 'is_public'],
'required' => ['title', 'message'],
'visibility' => 'is_public',
'order' => 'updated_at DESC',
'select' => 'id, title, severity, message, starts_at, ends_at, is_public, source, updated_at',
],
];
if (!isset($configs[$resource])) {
bmc_json_response(['error' => 'invalid_resource'], 404);
}
return $configs[$resource];
}
function cleanValues(array $input, array $config, bool $creating): array
{
$values = [];
foreach ($config['fields'] as $field) {
if (array_key_exists($field, $input)) {
$value = $input[$field];
if (in_array($field, ['is_active', 'is_public'], true)) {
$value = $value ? 1 : 0;
}
if ($field === 'sort_order') {
$value = max(0, (int)$value);
}
if ($field === 'severity' && !in_array($value, ['ok', 'info', 'warning', 'critical'], true)) {
bmc_json_response(['error' => 'validation_failed', 'message' => 'Ugyldig severity.'], 422);
}
if (in_array($field, ['starts_at', 'ends_at', 'website_url'], true) && $value === '') {
$value = null;
}
$values[$field] = $value;
}
}
if ($creating) {
foreach ($config['required'] as $field) {
if (!isset($values[$field]) || trim((string)$values[$field]) === '') {
bmc_json_response(['error' => 'validation_failed', 'message' => "$field mangler."], 422);
}
}
}
return $values;
}
function fetchItem(PDO $pdo, array $config, int $id): array
{
$statement = $pdo->prepare("SELECT {$config['select']} FROM {$config['table']} WHERE id = ? LIMIT 1");
$statement->execute([$id]);
$item = $statement->fetch();
if (!$item) {
bmc_json_response(['error' => 'not_found'], 404);
}
return $item;
}
function atomicWrite(string $path, string $contents): void
{
$temporary = $path . '.tmp.' . bin2hex(random_bytes(6));
if (file_put_contents($temporary, $contents, LOCK_EX) === false || !rename($temporary, $path)) {
@unlink($temporary);
throw new RuntimeException('Kunne ikke opdatere den offentlige content-cache.');
}
}
function refreshPublicCache(PDO $pdo): void
{
$customers = $pdo->query(
'SELECT customer_name, logo_url, website_url
FROM customer_references
WHERE is_active = 1
ORDER BY sort_order ASC, customer_name ASC
LIMIT 50'
)->fetchAll();
$current = $pdo->query(
'SELECT title, severity, message, starts_at, ends_at, updated_at
FROM operations_status
WHERE is_active = 1
AND (starts_at IS NULL OR starts_at <= NOW())
AND (ends_at IS NULL OR ends_at >= NOW())
ORDER BY updated_at DESC
LIMIT 1'
)->fetch() ?: null;
$history = $pdo->query(
'SELECT title, severity, message, starts_at, ends_at, updated_at
FROM operations_incidents
WHERE is_public = 1
ORDER BY updated_at DESC
LIMIT 20'
)->fetchAll();
$json = json_encode([
'meta' => ['generated_at' => gmdate('c')],
'customers' => $customers,
'operations' => ['current' => $current, 'history' => $history],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
atomicWrite(__DIR__ . '/content-cache.json', $json);
$logoDirectory = __DIR__ . '/content-cache-logos';
if (!is_dir($logoDirectory) && !mkdir($logoDirectory, 0755, true) && !is_dir($logoDirectory)) {
throw new RuntimeException('Kunne ikke oprette logo-cache.');
}
$extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'];
$logos = $pdo->query(
'SELECT id, logo_blob, logo_mime_type
FROM customer_references
WHERE is_active = 1 AND logo_blob IS NOT NULL'
)->fetchAll();
$activeFiles = [];
foreach ($logos as $logo) {
$extension = $extensions[(string)$logo['logo_mime_type']] ?? null;
if ($extension === null || !is_string($logo['logo_blob'])) {
continue;
}
$filename = (int)$logo['id'] . '.' . $extension;
atomicWrite($logoDirectory . '/' . $filename, $logo['logo_blob']);
$activeFiles[$filename] = true;
}
foreach (glob($logoDirectory . '/*.{png,jpg,webp,gif}', GLOB_BRACE) ?: [] as $cachedLogo) {
if (!isset($activeFiles[basename($cachedLogo)])) {
@unlink($cachedLogo);
}
}
}
function outputLogo(PDO $pdo, int $id): void
{
$statement = $pdo->prepare('SELECT logo_blob, logo_mime_type FROM customer_references WHERE id = ? LIMIT 1');
$statement->execute([$id]);
$logo = $statement->fetch();
if (!$logo || !is_string($logo['logo_blob'])) {
bmc_json_response(['error' => 'not_found'], 404);
}
header('Content-Type: ' . ($logo['logo_mime_type'] ?: 'application/octet-stream'));
header('Content-Length: ' . strlen($logo['logo_blob']));
header('Cache-Control: private, max-age=60');
header('X-Content-Type-Options: nosniff');
echo $logo['logo_blob'];
exit;
}
function uploadLogo(PDO $pdo, int $id, array $config): void
{
if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
bmc_json_response(['error' => 'invalid_upload', 'message' => 'Logo mangler.'], 422);
}
$file = $_FILES['logo'];
if ((int)$file['size'] < 1 || (int)$file['size'] > MAX_LOGO_BYTES) {
bmc_json_response(['error' => 'file_too_large', 'message' => 'Logo må højst fylde 5 MB.'], 413);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!in_array($mime, ALLOWED_LOGO_TYPES, true)) {
bmc_json_response(['error' => 'invalid_file_type'], 415);
}
$blob = file_get_contents($file['tmp_name']);
$logoUrl = '/api/content.php?logo=' . $id;
$statement = $pdo->prepare(
'UPDATE customer_references SET logo_blob = ?, logo_mime_type = ?, logo_url = ? WHERE id = ?'
);
$statement->bindParam(1, $blob, PDO::PARAM_LOB);
$statement->bindValue(2, $mime);
$statement->bindValue(3, $logoUrl);
$statement->bindValue(4, $id, PDO::PARAM_INT);
$statement->execute();
refreshPublicCache($pdo);
bmc_json_response(fetchItem($pdo, $config, $id));
}
$adminCredential = requireAdminToken();
$resource = (string)($_GET['resource'] ?? '');
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null;
$action = (string)($_GET['action'] ?? '');
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$config = resourceConfig($resource);
try {
$pdo = adminDb($adminCredential);
if ($resource === 'customers' && $id && $action === 'logo') {
if ($method === 'GET') {
outputLogo($pdo, $id);
}
uploadLogo($pdo, $id, $config);
}
// A normal authenticated read also repairs/initializes the public cache.
refreshPublicCache($pdo);
if ($resource === 'operations' && $id && $action === 'complete' && $method === 'POST') {
$input = body();
$endedAt = $input['ends_at'] ?: date('Y-m-d H:i:s');
$pdo->beginTransaction();
try {
$statement = $pdo->prepare('SELECT * FROM operations_status WHERE id = ? FOR UPDATE');
$statement->execute([$id]);
$operation = $statement->fetch();
if (!$operation) {
$pdo->rollBack();
bmc_json_response(['error' => 'not_found'], 404);
}
$insert = $pdo->prepare(
"INSERT INTO operations_incidents
(title, severity, message, starts_at, ends_at, is_public, source)
VALUES (?, ?, ?, ?, ?, ?, 'hub')"
);
$insert->execute([
$operation['title'], $operation['severity'], $operation['message'],
$operation['starts_at'], $endedAt, !empty($input['is_public']) ? 1 : 0,
]);
$incidentId = (int)$pdo->lastInsertId();
$pdo->prepare('UPDATE operations_status SET is_active = 0, ends_at = ? WHERE id = ?')
->execute([$endedAt, $id]);
$pdo->commit();
refreshPublicCache($pdo);
bmc_json_response(fetchItem($pdo, resourceConfig('incidents'), $incidentId), 201);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
if ($method === 'GET' && $id) {
bmc_json_response(fetchItem($pdo, $config, $id));
}
if ($method === 'GET') {
$where = !filter_var($_GET['include_hidden'] ?? true, FILTER_VALIDATE_BOOL)
? " WHERE {$config['visibility']} = 1" : '';
$items = $pdo->query("SELECT {$config['select']} FROM {$config['table']}{$where} ORDER BY {$config['order']}")
->fetchAll();
bmc_json_response(['items' => $items]);
}
if ($method === 'POST' && !$id) {
$values = cleanValues(body(), $config, true);
$values['source'] = 'hub';
if ($resource === 'customers' && empty($values['logo_url'])) {
$values['logo_url'] = '';
}
$columns = array_keys($values);
$sql = "INSERT INTO {$config['table']} (" . implode(',', $columns) . ') VALUES ('
. implode(',', array_fill(0, count($columns), '?')) . ')';
$pdo->prepare($sql)->execute(array_values($values));
$newId = (int)$pdo->lastInsertId();
if ($resource === 'customers' && $values['logo_url'] === '') {
$pdo->prepare('UPDATE customer_references SET logo_url = ? WHERE id = ?')
->execute(['/api/content.php?logo=' . $newId, $newId]);
}
refreshPublicCache($pdo);
bmc_json_response(fetchItem($pdo, $config, $newId), 201);
}
if ($method === 'PATCH' && $id) {
$values = cleanValues(body(), $config, false);
if (!$values) {
bmc_json_response(fetchItem($pdo, $config, $id));
}
$assignments = implode(',', array_map(fn($field) => "$field = ?", array_keys($values)));
$pdo->prepare("UPDATE {$config['table']} SET $assignments WHERE id = ?")
->execute([...array_values($values), $id]);
refreshPublicCache($pdo);
bmc_json_response(fetchItem($pdo, $config, $id));
}
bmc_json_response(['error' => 'method_not_allowed'], 405);
} catch (Throwable $e) {
error_log('admin-content.php: ' . $e->getMessage());
bmc_json_response([
'error' => 'content_admin_unavailable',
'message' => 'Website-databasen er ikke tilgængelig: ' . $e->getMessage(),
], 503);
}

163
deploy/website/content.php Normal file
View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
function outputCustomerLogo(PDO $pdo, int $id): void
{
$statement = $pdo->prepare(
'SELECT logo_blob, logo_mime_type, updated_at
FROM customer_references
WHERE id = ? AND is_active = 1 AND logo_blob IS NOT NULL
LIMIT 1'
);
$statement->execute([$id]);
$logo = $statement->fetch();
$allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
if (!$logo || !is_string($logo['logo_blob'])) {
http_response_code(404);
exit;
}
$mime = (string)($logo['logo_mime_type'] ?? '');
if (!in_array($mime, $allowed, true)) {
http_response_code(415);
exit;
}
$etag = '"' . sha1((string)$logo['updated_at'] . ':' . strlen($logo['logo_blob'])) . '"';
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
http_response_code(304);
exit;
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . strlen($logo['logo_blob']));
header('Cache-Control: public, max-age=3600');
header('ETag: ' . $etag);
header('X-Content-Type-Options: nosniff');
echo $logo['logo_blob'];
exit;
}
function outputCachedCustomerLogo(int $id): void
{
$types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'gif' => 'image/gif'];
foreach ($types as $extension => $mime) {
$path = __DIR__ . '/content-cache-logos/' . $id . '.' . $extension;
if (!is_file($path)) {
continue;
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=3600');
header('X-Content-Type-Options: nosniff');
readfile($path);
exit;
}
http_response_code(404);
exit;
}
function cachedContent(): ?array
{
$path = __DIR__ . '/content-cache.json';
if (!is_file($path)) {
return null;
}
$decoded = json_decode((string)file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
if (isset($_GET['logo'])) {
$logoId = filter_input(INPUT_GET, 'logo', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if (!$logoId) {
http_response_code(400);
exit;
}
try {
outputCustomerLogo(bmc_db(), $logoId);
} catch (Throwable $e) {
outputCachedCustomerLogo($logoId);
}
}
function fetchCustomers(PDO $pdo): array
{
$sql = "SELECT customer_name, logo_url, website_url
FROM customer_references
WHERE is_active = 1
ORDER BY sort_order ASC, customer_name ASC
LIMIT 50";
return $pdo->query($sql)->fetchAll();
}
function fetchCurrentOperation(PDO $pdo): ?array
{
$sql = "SELECT title, severity, message, starts_at, ends_at, updated_at
FROM operations_status
WHERE is_active = 1
AND (starts_at IS NULL OR starts_at <= NOW())
AND (ends_at IS NULL OR ends_at >= NOW())
ORDER BY updated_at DESC
LIMIT 1";
$row = $pdo->query($sql)->fetch();
return $row ?: null;
}
function fetchOperationHistory(PDO $pdo): array
{
$sql = "SELECT title, severity, message, starts_at, ends_at, updated_at
FROM operations_incidents
WHERE is_public = 1
ORDER BY updated_at DESC
LIMIT 20";
return $pdo->query($sql)->fetchAll();
}
try {
$pdo = bmc_db();
$customers = [];
$current = null;
$history = [];
try {
$customers = fetchCustomers($pdo);
} catch (Throwable $e) {
$customers = [];
}
try {
$current = fetchCurrentOperation($pdo);
} catch (Throwable $e) {
$current = null;
}
try {
$history = fetchOperationHistory($pdo);
} catch (Throwable $e) {
$history = [];
}
bmc_json_response([
'meta' => [
'generated_at' => gmdate('c'),
],
'customers' => $customers,
'operations' => [
'current' => $current,
'history' => $history,
],
]);
} catch (Throwable $e) {
$cached = cachedContent();
if ($cached !== null) {
bmc_json_response($cached);
}
bmc_json_response([
'error' => 'content_unavailable',
'message' => 'Kunne ikke hente dynamisk indhold.',
], 503);
}

View File

@ -152,6 +152,8 @@ from app.modules.invoice_error_finder.backend import router as invoice_error_fin
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.modules.website_content.backend import router as website_content_api
from app.modules.website_content.frontend import views as website_content_views
from app.bug_reports.backend import router as bug_reports_api
# Configure logging
@ -505,6 +507,7 @@ 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"])
app.include_router(website_content_api.router, prefix="/api/v1/website-content", tags=["Website Content"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@ -545,6 +548,7 @@ 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"])
app.include_router(website_content_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,123 @@
-- Subscription agreement overview, approval workflow and invoice idempotency.
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS billing_schedule_type VARCHAR(30) NOT NULL DEFAULT 'fixed_day',
ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_status_check;
ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_status_check
CHECK (status IN ('draft','scheduled','active','paused','terminating','cancelled','expired','blocked')) NOT VALID;
ALTER TABLE sag_subscriptions VALIDATE CONSTRAINT sag_subscriptions_status_check;
ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_billing_schedule_type_check;
ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_billing_schedule_type_check
CHECK (billing_schedule_type IN ('fixed_day','first_business_day','last_business_day','interval_anchor'));
CREATE TABLE IF NOT EXISTS subscription_change_requests (
id BIGSERIAL PRIMARY KEY,
main_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
change_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
status VARCHAR(30) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','pending','approved_scheduled','applying','partially_applied','applied','rejected','failed','cancellation_pending','cancelled')),
reason TEXT,
effective_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_by_user_id INTEGER NOT NULL REFERENCES users(user_id),
submitted_at TIMESTAMP,
approved_by_user_id INTEGER REFERENCES users(user_id),
approved_at TIMESTAMP,
rejected_by_user_id INTEGER REFERENCES users(user_id),
rejected_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_change_request_open_per_sag
ON subscription_change_requests(main_sag_id)
WHERE status IN ('draft','pending','approved_scheduled','applying','partially_applied','failed','cancellation_pending');
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_change_request_case
ON subscription_change_requests(change_sag_id);
CREATE TABLE IF NOT EXISTS subscription_change_request_items (
id BIGSERIAL PRIMARY KEY,
change_request_id BIGINT NOT NULL REFERENCES subscription_change_requests(id) ON DELETE CASCADE,
subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE RESTRICT,
base_version INTEGER NOT NULL,
before_snapshot JSONB NOT NULL,
proposed_snapshot JSONB NOT NULL,
apply_status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (apply_status IN ('pending','applied','failed','abandoned')),
error_message TEXT,
applied_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(change_request_id, subscription_id)
);
CREATE INDEX IF NOT EXISTS idx_subscription_change_items_subscription
ON subscription_change_request_items(subscription_id);
CREATE TABLE IF NOT EXISTS subscription_change_cancellations (
id BIGSERIAL PRIMARY KEY,
change_request_id BIGINT NOT NULL REFERENCES subscription_change_requests(id) ON DELETE CASCADE,
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
requested_by_user_id INTEGER NOT NULL REFERENCES users(user_id),
decided_by_user_id INTEGER REFERENCES users(user_id),
decided_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_change_cancellation_pending
ON subscription_change_cancellations(change_request_id) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS subscription_events (
id BIGSERIAL PRIMARY KEY,
main_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
change_request_id BIGINT REFERENCES subscription_change_requests(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL,
actor_user_id INTEGER REFERENCES users(user_id),
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subscription_events_sag_created
ON subscription_events(main_sag_id, created_at DESC);
CREATE TABLE IF NOT EXISTS subscription_billing_runs (
id BIGSERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE RESTRICT,
period_start DATE NOT NULL,
ordre_draft_id INTEGER REFERENCES ordre_drafts(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(subscription_id, period_start)
);
INSERT INTO permissions (code, description, category) VALUES
('subscriptions.view', 'View subscriptions and agreement history', 'subscriptions'),
('subscriptions.change_request', 'Create subscription change requests', 'subscriptions'),
('subscriptions.approve', 'Approve and retry subscription changes', 'subscriptions')
ON CONFLICT (code) DO UPDATE SET
description = EXCLUDED.description,
category = EXCLUDED.category;
WITH permission_map(subscription_code, case_code) AS (
VALUES
('subscriptions.view', 'cases.view'),
('subscriptions.change_request', 'cases.edit')
)
INSERT INTO group_permissions (group_id, permission_id)
SELECT DISTINCT gp.group_id, subscription_permission.id
FROM group_permissions gp
JOIN permissions case_permission ON case_permission.id = gp.permission_id
JOIN permission_map mapping ON mapping.case_code = case_permission.code
JOIN permissions subscription_permission ON subscription_permission.code = mapping.subscription_code
ON CONFLICT DO NOTHING;
-- Existing out-of-range days need an explicit review instead of silent correction.
UPDATE sag_subscriptions
SET billing_blocked = TRUE,
billing_block_reason = CONCAT_WS('; ', NULLIF(billing_block_reason, ''), 'Fakturadag 29-31 kræver manuel gennemgang')
WHERE billing_day > 28
AND COALESCE(billing_block_reason, '') NOT LIKE '%Fakturadag 29-31 kræver manuel gennemgang%';
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_sag_lifecycle
ON sag_subscriptions(sag_id, status, start_date, end_date);

View File

@ -0,0 +1,20 @@
-- One-time charges that are included once, on the first generated invoice.
CREATE TABLE IF NOT EXISTS sag_subscription_first_invoice_items (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
product_id BIGINT REFERENCES products(id) ON DELETE SET NULL,
description TEXT NOT NULL,
quantity NUMERIC(14,4) NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (unit_price >= 0),
line_total NUMERIC(14,2) NOT NULL DEFAULT 0,
billed_at TIMESTAMPTZ,
billing_run_id BIGINT REFERENCES subscription_billing_runs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (subscription_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_subscription_first_invoice_unbilled
ON sag_subscription_first_invoice_items(subscription_id)
WHERE billed_at IS NULL;

View File

@ -0,0 +1,11 @@
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS billing_lead_months INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS proration_basis VARCHAR(20) NOT NULL DEFAULT '30_day';
ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_billing_lead_months_check;
ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_billing_lead_months_check
CHECK (billing_lead_months BETWEEN 0 AND 24);
ALTER TABLE sag_subscriptions DROP CONSTRAINT IF EXISTS sag_subscriptions_proration_basis_check;
ALTER TABLE sag_subscriptions ADD CONSTRAINT sag_subscriptions_proration_basis_check
CHECK (proration_basis IN ('30_day'));

View File

@ -0,0 +1,31 @@
-- Ensure every saved billing rule can be executed by the invoice job.
UPDATE sag_subscriptions
SET billing_schedule_type = 'interval_anchor',
updated_at = CURRENT_TIMESTAMP
WHERE billing_interval IN ('daily', 'biweekly')
AND billing_schedule_type IS DISTINCT FROM 'interval_anchor';
UPDATE sag_subscriptions
SET billing_schedule_type = 'fixed_day',
updated_at = CURRENT_TIMESTAMP
WHERE billing_interval IN ('monthly', 'quarterly', 'yearly')
AND billing_schedule_type = 'interval_anchor';
ALTER TABLE sag_subscriptions
DROP CONSTRAINT IF EXISTS sag_subscriptions_runnable_schedule_check;
ALTER TABLE sag_subscriptions
ADD CONSTRAINT sag_subscriptions_runnable_schedule_check CHECK (
(billing_interval IN ('daily', 'biweekly') AND billing_schedule_type = 'interval_anchor')
OR
(billing_interval IN ('monthly', 'quarterly', 'yearly')
AND billing_schedule_type IN ('fixed_day', 'first_business_day', 'last_business_day'))
);
-- Existing legacy days 29-31 remain visible for manual review, but new/updated
-- fixed-day rules must always be executable.
ALTER TABLE sag_subscriptions
DROP CONSTRAINT IF EXISTS sag_subscriptions_runnable_billing_day_check;
ALTER TABLE sag_subscriptions
ADD CONSTRAINT sag_subscriptions_runnable_billing_day_check CHECK (
billing_schedule_type <> 'fixed_day' OR billing_day BETWEEN 1 AND 28
) NOT VALID;

View File

@ -0,0 +1,18 @@
INSERT INTO permissions (code, description, category) VALUES
('website_content.view', 'Se administration af website-indhold', 'website_content'),
('website_content.edit', 'Rediger website-indhold og driftsstatus', 'website_content')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Administrators' AND p.category = 'website_content'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Managers' AND p.category = 'website_content'
ON CONFLICT DO NOTHING;

View File

@ -5,6 +5,7 @@ pydantic==2.10.3
pydantic-settings==2.6.1
python-dotenv==1.0.1
python-multipart==0.0.17
extract-msg==0.56.1
python-dateutil==2.8.2
jinja2==3.1.4
aiohttp==3.10.10

View File

@ -1,4 +1,5 @@
import asyncio
import io
import sys
from pathlib import Path
@ -52,3 +53,162 @@ def test_router_simple_create_contact_supports_extended_payload_and_company_link
assert company_link_params[0][1] == 10
assert company_link_params[1][1] == 20
assert update_calls
def test_contact_email_regex_uses_exact_address_boundaries():
import re
from app.contacts.backend.router_simple import _exact_email_pattern
pattern = re.compile(_exact_email_pattern("ada@example.com"))
assert pattern.search("Ada <ada@example.com>, other@example.com")
assert not pattern.search("notada@example.com")
assert not pattern.search("ada@example.com.evil.test")
def test_contact_detail_has_cases_and_email_tabs():
template = Path("app/contacts/frontend/contact_detail.html").read_text(encoding="utf-8")
assert 'href="#cases"' in template
assert 'href="#emails"' in template
assert "/cases?limit=${contactRelatedPageSize}" in template
assert "/emails?limit=${contactRelatedPageSize}" in template
def test_contact_email_analysis_returns_review_only_changes():
from app.contacts.backend.router_simple import _contact_suggestions_from_email
contact = {
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@old.example",
"phone": "11111111",
"mobile": None,
"title": "Udvikler",
"department": None,
}
parsed = {
"sender_name": "Ada Lovelace <ada@new.example>",
"sender_email": "ada@new.example",
"recipient_email": "support@bmc.example",
"body_text": "Hej\n\nMobil: +45 22 33 44 55\nTitel: CTO\nAfdeling: IT\n",
}
suggestions = _contact_suggestions_from_email(contact, parsed)
by_field = {item["field"]: item for item in suggestions}
assert by_field["email"]["suggested"] == "ada@new.example"
assert by_field["mobile"]["suggested"] == "+45 22 33 44 55"
assert by_field["title"]["suggested"] == "CTO"
assert by_field["department"]["suggested"] == "IT"
assert "first_name" not in by_field
assert "last_name" not in by_field
assert "phone" not in by_field
def test_contact_email_analysis_does_not_treat_our_sender_as_the_contact():
from app.contacts.backend.router_simple import _contact_suggestions_from_email
contact = {"email": "customer@example.com"}
parsed = {
"sender_name": "Support Agent",
"sender_email": "support@bmc.example",
"recipient_email": "Customer <customer@example.com>",
"body_text": "Venlig hilsen",
}
assert _contact_suggestions_from_email(contact, parsed) == []
def test_contact_email_analysis_handles_createx_outlook_signature():
from app.contacts.backend.router_simple import _contact_suggestions_from_email
contact = {
"first_name": "Ida", "last_name": "Gundersen",
"email": "ida@createx-onstage.com", "mobile": None, "title": None,
}
parsed = {
"sender_name": "Ida <ida@createx-onstage.com>",
"sender_email": "ida@createx-onstage.com",
"recipient_email": "support@example.com",
"body_text": """Ida
Kind regards
**Ida Gundersen**
*Technical Advisor & Co-owner*
**Mobile:** +45 42 25 59 08 **DK:&amp;#xA0;**+45 55 86 05 00
**FI:** +358 40 550 5865 **NO:** +47 62 41 84 05
**Email:** ida\\@createx-onstage.com
""",
}
by_field = {
item["field"]: item
for item in _contact_suggestions_from_email(contact, parsed)
}
assert by_field["mobile"]["suggested"] == "+45 42 25 59 08"
assert by_field["title"]["suggested"] == "Technical Advisor & Co-owner"
def test_contact_detail_has_outlook_dropzone_and_review_modal():
template = Path("app/contacts/frontend/contact_detail.html").read_text(encoding="utf-8")
assert 'id="contactEmailDropzone"' in template
assert 'accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook"' in template
assert "/analyze-email" in template
assert 'id="contactEmailSuggestionsModal"' in template
assert "applyContactEmailSuggestions()" in template
def test_new_contact_email_analysis_extracts_company_cvr_and_name():
from app.contacts.backend.router_simple import _company_from_email_body
company = _company_from_email_body(
"Ida Gundersen\nTechnical Advisor & Co-owner\nCreatex ApS\nStoregade 4C | 4780 Stege\nCVR: 12 34 56 78",
"Ida Gundersen",
)
assert company == {"name": "Createx ApS", "cvr_number": "12345678"}
def test_contacts_page_can_create_contact_from_email():
template = Path("app/contacts/frontend/contacts.html").read_text(encoding="utf-8")
assert "Træk Outlook-mail hertil" in template
assert 'id="createFromEmailInput"' in template
assert 'id="createFromEmailDropzone"' in template
assert "event.dataTransfer?.files?.[0]" in template
assert "initializeCreateFromEmailDropzone()" in template
assert "'/api/v1/contacts/analyze-email'" in template
assert "'/api/v1/contacts/resolve-email-company'" in template
assert 'id="createFromEmailModal"' in template
def test_new_contact_email_uses_existing_cvr_lookup(monkeypatch):
from starlette.datastructures import UploadFile
from app.contacts.backend import router_simple
from app.services.email_service import EmailService
parsed = {
"sender_name": "Ida Gundersen", "sender_email": "ida@example.com",
"recipient_email": "support@example.com", "subject": "Hej",
"body_text": "Ida Gundersen\nRådgiver\nForkert navn\nStoregade 4, 4780 Stege\nCVR: 12345678",
}
class FakeCvrService:
async def lookup_by_cvr(self, cvr):
assert cvr == "12345678"
return {"name": "Officielt Firma ApS", "address": "Torvet 1", "postal_code": "4780", "city": "Stege", "source": "firmaapi"}
monkeypatch.setattr(EmailService, "parse_eml_file", lambda self, content: parsed)
monkeypatch.setattr(router_simple, "get_cvr_service", lambda: FakeCvrService())
monkeypatch.setattr(router_simple, "execute_query_single", lambda *args, **kwargs: None)
result = asyncio.run(router_simple.analyze_email_for_new_contact(
UploadFile(filename="mail.eml", file=io.BytesIO(b"mail"))
))
assert result["company"]["lookup_found"] is True
assert result["company"]["name"] == "Officielt Firma ApS"
assert result["company"]["address"] == "Torvet 1"
def test_contacts_without_search_uses_valid_neutral_ordering():
source = Path("app/contacts/backend/router_simple.py").read_text(encoding="utf-8")
assert 'rank_order_sql = ""' in source
assert "ORDER BY {rank_order_sql} c.last_name" in source
assert 'rank_sql = "0"' not in source

View File

@ -0,0 +1,23 @@
import re
from pathlib import Path
def test_customer_email_patterns_are_exact():
from app.customers.backend.router import _email_address_pattern, _email_domain_pattern
address = re.compile(_email_address_pattern("info@example.com"))
assert address.search("Info <info@example.com>")
assert not address.search("otherinfo@example.com")
domain = re.compile(_email_domain_pattern("example.com"))
assert domain.search("person@example.com")
assert not domain.search("person@example.com.evil.test")
assert not domain.search("person@notexample.com")
def test_customer_detail_exposes_email_tab_and_supplier_service_checkbox():
template = Path("app/customers/frontend/customer_detail.html").read_text(encoding="utf-8")
assert 'href="#emails"' in template
assert 'id="supplierServiceEnrolled"' in template
assert "supplier_service_enrolled: checkbox.checked" in template
assert "/emails?limit=${customerEmailsLimit}" in template

View File

@ -155,6 +155,24 @@ def test_case_create_lists_contacts_for_selected_customer():
assert "resetCustomerContactSearch();" in template
def test_case_create_defaults_responsible_to_current_user():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
assert "selectCurrentUserAsResponsible();" in template
assert "raw_responsible = data.get" in router
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
def test_case_v3_contact_actions_and_company_link_include_case_context():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert 'href="/customers/{{ customer.id }}"' in template
assert "sag_id: {{ case.id }}" in template
assert "contact_id: opts.contactId || null" in template
assert 'title="Ring til mobil"' in template
assert 'title="Send SMS"' in template
assert 'id="caseCallHistoryBody"' in template
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()

View File

@ -0,0 +1,75 @@
from datetime import date
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.services.subscription_billing_calendar import (
advance_billing_periods,
billing_date_for_period,
is_danish_bank_day,
next_billing_date,
prorated_30_day_factor,
resolve_month_date,
validate_billing_schedule,
)
import pytest
from app.services.subscription_agreement import agreement_status
def test_fixed_day_is_resolved_in_target_month():
assert next_billing_date(date(2026, 1, 17), "monthly", "fixed_day", 5) == date(2026, 2, 5)
def test_first_bank_day_skips_weekend_and_new_year():
assert resolve_month_date(2026, 1, "first_business_day", None) == date(2026, 1, 2)
def test_last_bank_day_skips_new_years_eve():
assert resolve_month_date(2026, 12, "last_business_day", None) == date(2026, 12, 30)
def test_bank_holiday_after_ascension_is_closed():
assert not is_danish_bank_day(date(2026, 5, 15))
def test_biweekly_keeps_exact_interval_even_with_month_schedule():
assert next_billing_date(date(2026, 4, 1), "biweekly", "first_business_day", 1) == date(2026, 4, 15)
def test_short_intervals_are_forced_to_runnable_anchor_schedule():
assert validate_billing_schedule("daily", "fixed_day", 17) == ("interval_anchor", 17)
def test_monthly_interval_anchor_is_rejected():
with pytest.raises(ValueError, match="only valid"):
validate_billing_schedule("monthly", "interval_anchor", 1)
def test_new_fixed_day_rules_cannot_use_day_29_to_31():
with pytest.raises(ValueError, match="between 1 and 28"):
validate_billing_schedule("quarterly", "fixed_day", 31)
def test_period_can_be_invoiced_two_months_in_advance():
assert billing_date_for_period(date(2027, 1, 1), 2, "fixed_day", 1) == date(2026, 11, 1)
def test_three_monthly_periods_cover_a_quarter():
assert advance_billing_periods(date(2027, 1, 1), "monthly", 3) == date(2027, 4, 1)
def test_short_opening_period_uses_30_day_basis():
assert prorated_30_day_factor(date(2027, 1, 5), date(2027, 2, 1)) == 26 / 30
def test_agreement_status_prioritizes_failures_and_pending_changes():
subscriptions = [{"status": "active"}, {"status": "paused"}]
assert agreement_status(subscriptions, [{"status": "failed"}]) == "Kræver handling"
assert agreement_status(subscriptions, [{"status": "pending"}]) == "Ændring afventer"
assert agreement_status(subscriptions, []) == "Delvist pauseret"
def test_agreement_status_handles_partial_termination_and_closed():
assert agreement_status([{"status": "active"}, {"status": "cancelled"}], []) == "Delvist opsagt"
assert agreement_status([{"status": "expired"}, {"status": "cancelled"}], []) == "Afsluttet"

View File

@ -118,3 +118,16 @@ def test_stale_termination_duration_is_not_derived_from_current_time(monkeypatch
assert TelefoniService.terminate_call("stale-call", None) is True
assert "INTERVAL '12 hours'" in queries[0]
def test_case_click_to_call_contract_tracks_case_contact_and_time():
schema = Path("app/modules/telefoni/backend/schemas.py").read_text(encoding="utf-8")
router = Path("app/modules/telefoni/backend/router.py").read_text(encoding="utf-8")
service = Path("app/modules/telefoni/backend/service.py").read_text(encoding="utf-8")
assert "sag_id: Optional[int]" in schema
assert "contact_id: Optional[int]" in schema
assert 'pending_callid = f"click-to-call:' in router
assert "_register_completed_call_time(resolved_callid)" in router
assert "INSERT INTO tmodule_times" in router
assert "callid LIKE 'click-to-call:%'" in service

View File

@ -0,0 +1,60 @@
from datetime import datetime
import json
import httpx
import pytest
from app.modules.website_content.backend.service import NotFoundError, WebsiteContentAPIError, WebsiteContentService
def service_for(handler):
return WebsiteContentService(
"https://website.test/api/admin-content.php", "secret",
httpx.Client(transport=httpx.MockTransport(handler)),
)
def test_list_uses_authenticated_https_api():
def handler(request):
assert request.headers["x-website-admin-token"] == "secret"
assert request.url.params["resource"] == "customers"
assert request.url.params["include_hidden"] == "1"
return httpx.Response(200, json={"items": [{"id": 1, "customer_name": "Kunde"}]})
assert service_for(handler).list("customers") == [{"id": 1, "customer_name": "Kunde"}]
def test_complete_operation_calls_transactional_webhook_action():
def handler(request):
assert request.method == "POST"
assert request.url.params["resource"] == "operations"
assert request.url.params["id"] == "12"
assert request.url.params["action"] == "complete"
payload = json.loads(request.content)
assert payload == {"ends_at": "2026-08-26T10:00:00", "is_public": True}
return httpx.Response(201, json={"id": 77, "title": "Fiberfejl"})
result = service_for(handler).complete_operation(12, datetime(2026, 8, 26, 10), True)
assert result["id"] == 77
def test_logo_is_sent_as_multipart():
def handler(request):
assert request.url.params["action"] == "logo"
assert request.headers["content-type"].startswith("multipart/form-data")
assert b"PNG-data" in request.content
return httpx.Response(200, json={"id": 3, "logo_url": "/api/content.php?logo=3"})
assert service_for(handler).upload_logo(3, b"PNG-data", "image/png")["id"] == 3
def test_404_is_mapped_to_not_found():
service = service_for(lambda _request: httpx.Response(404, json={"error": "not_found"}))
with pytest.raises(NotFoundError):
service.get("customers", 999)
def test_missing_token_is_rejected_before_network_call():
service = WebsiteContentService("https://website.test/admin.php", "", httpx.Client())
with pytest.raises(WebsiteContentAPIError, match="ikke konfigureret"):
service.list("customers")