release: v2.7.2
This commit is contained in:
parent
2c2db54d16
commit
0a12006f87
@ -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
|
||||
# =====================================================
|
||||
|
||||
30
MDfile/RELEASE_NOTES_v2.7.2.md
Normal file
30
MDfile/RELEASE_NOTES_v2.7.2.md
Normal 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.
|
||||
@ -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 []
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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 '-';
|
||||
|
||||
@ -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(
|
||||
"""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
1
app/modules/website_content/__init__.py
Normal file
1
app/modules/website_content/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Administration of content published on bmcnetworks.dk."""
|
||||
1
app/modules/website_content/backend/__init__.py
Normal file
1
app/modules/website_content/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Website content backend."""
|
||||
152
app/modules/website_content/backend/router.py
Normal file
152
app/modules/website_content/backend/router.py
Normal 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)
|
||||
)
|
||||
69
app/modules/website_content/backend/schemas.py
Normal file
69
app/modules/website_content/backend/schemas.py
Normal 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
|
||||
99
app/modules/website_content/backend/service.py
Normal file
99
app/modules/website_content/backend/service.py
Normal 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()
|
||||
1
app/modules/website_content/frontend/__init__.py
Normal file
1
app/modules/website_content/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Website content frontend."""
|
||||
19
app/modules/website_content/frontend/views.py
Normal file
19
app/modules/website_content/frontend/views.py
Normal 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},
|
||||
)
|
||||
11
app/modules/website_content/module.json
Normal file
11
app/modules/website_content/module.json
Normal 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"]
|
||||
}
|
||||
127
app/modules/website_content/templates/index.html
Normal file
127
app/modules/website_content/templates/index.html
Normal 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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||
28
app/services/subscription_agreement.py
Normal file
28
app/services/subscription_agreement.py
Normal 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"
|
||||
132
app/services/subscription_billing_calendar.py
Normal file
132
app/services/subscription_billing_calendar.py
Normal 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))
|
||||
@ -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
@ -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 (1–28)</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>
|
||||
|
||||
344
deploy/website/admin-content.php
Normal file
344
deploy/website/admin-content.php
Normal 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
163
deploy/website/content.php
Normal 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);
|
||||
}
|
||||
4
main.py
4
main.py
@ -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
|
||||
|
||||
123
migrations/1019_subscription_change_workflow.sql
Normal file
123
migrations/1019_subscription_change_workflow.sql
Normal 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);
|
||||
20
migrations/1020_subscription_first_invoice_items.sql
Normal file
20
migrations/1020_subscription_first_invoice_items.sql
Normal 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;
|
||||
@ -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'));
|
||||
31
migrations/1022_subscription_schedule_integrity.sql
Normal file
31
migrations/1022_subscription_schedule_integrity.sql
Normal 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;
|
||||
18
migrations/232_website_content_permissions.sql
Normal file
18
migrations/232_website_content_permissions.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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:&#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
|
||||
|
||||
23
tests/test_customer_crm_improvements.py
Normal file
23
tests/test_customer_crm_improvements.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
|
||||
75
tests/test_subscription_billing_calendar.py
Normal file
75
tests/test_subscription_billing_calendar.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
60
tests/test_website_content.py
Normal file
60
tests/test_website_content.py
Normal 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")
|
||||
Loading…
Reference in New Issue
Block a user