release: v2.7.2
This commit is contained in:
parent
2c2db54d16
commit
ffdc9ac62c
@ -10,6 +10,12 @@ POSTGRES_PASSWORD=bmc_hub
|
|||||||
POSTGRES_DB=bmc_hub
|
POSTGRES_DB=bmc_hub
|
||||||
POSTGRES_PORT=5433 # Changed from 5432 to avoid conflicts with other services
|
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
|
# 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
|
Only GET endpoints for now
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Body, status
|
from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.core.database import (
|
from app.core.database import (
|
||||||
execute_query,
|
execute_query,
|
||||||
@ -15,6 +15,7 @@ from app.core.database import (
|
|||||||
)
|
)
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
|
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 (
|
from app.customers.backend.router import (
|
||||||
get_customer_subscriptions,
|
get_customer_subscriptions,
|
||||||
lock_customer_subscriptions,
|
lock_customer_subscriptions,
|
||||||
@ -22,9 +23,14 @@ from app.customers.backend.router import (
|
|||||||
get_subscription_comment,
|
get_subscription_comment,
|
||||||
get_subscription_billing_matrix,
|
get_subscription_billing_matrix,
|
||||||
SubscriptionComment,
|
SubscriptionComment,
|
||||||
|
CustomerCreate,
|
||||||
|
create_customer,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
from email.utils import parseaddr
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -63,6 +69,17 @@ class ContactMergeRequest(BaseModel):
|
|||||||
source_contact_id: int = Field(..., gt=0)
|
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 = (
|
CONTACT_MERGE_RELATIONS = (
|
||||||
("Firmaer", "contact_companies", "contact_id"),
|
("Firmaer", "contact_companies", "contact_id"),
|
||||||
("Sager", "sag_kontakter", "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):
|
class ContactCompanyLink(BaseModel):
|
||||||
customer_id: int
|
customer_id: int
|
||||||
is_primary: bool = True
|
is_primary: bool = True
|
||||||
@ -147,28 +483,32 @@ async def get_contacts(
|
|||||||
where_clauses = []
|
where_clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
normalized_search = " ".join(str(search or "").split())
|
||||||
where_clauses.append(
|
if normalized_search:
|
||||||
"""
|
for token in normalized_search.split(" "):
|
||||||
|
token_clause = """
|
||||||
(
|
(
|
||||||
c.first_name ILIKE %s
|
CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s
|
||||||
OR c.last_name ILIKE %s
|
OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s
|
||||||
OR c.email ILIKE %s
|
OR COALESCE(c.email, '') ILIKE %s
|
||||||
OR c.phone ILIKE %s
|
OR COALESCE(c.title, '') ILIKE %s
|
||||||
OR c.mobile ILIKE %s
|
OR COALESCE(c.department, '') ILIKE %s
|
||||||
OR c.user_company ILIKE %s
|
OR COALESCE(c.user_company, '') ILIKE %s
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1 FROM contact_companies cc2
|
||||||
FROM contact_companies cc2
|
|
||||||
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
||||||
WHERE cc2.contact_id = c.id
|
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s
|
||||||
AND cu2.name ILIKE %s
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
token_params: list[Any] = [f"%{token}%"] * 7
|
||||||
like = f"%{search}%"
|
digits = "".join(ch for ch in token if ch.isdigit())
|
||||||
params.extend([like, like, like, like, like, like, like])
|
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:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
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
|
total = count_result[0]['count'] if count_result else 0
|
||||||
|
|
||||||
# Step 1: Fetch contacts only (stable pagination)
|
# 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"""
|
contacts_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
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
|
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
{where_sql}
|
{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
|
LIMIT %s OFFSET %s
|
||||||
"""
|
"""
|
||||||
contacts_params = list(params)
|
contacts_params = list(params)
|
||||||
|
contacts_params.extend(rank_params)
|
||||||
contacts_params.extend([limit, offset])
|
contacts_params.extend([limit, offset])
|
||||||
contacts = execute_query(contacts_query, tuple(contacts_params)) or []
|
contacts = execute_query(contacts_query, tuple(contacts_params)) or []
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,21 @@
|
|||||||
top: 1rem;
|
top: 1rem;
|
||||||
right: 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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -150,6 +165,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Alert Notes Container -->
|
||||||
<div id="alert-notes-container"></div>
|
<div id="alert-notes-container"></div>
|
||||||
|
|
||||||
@ -173,6 +200,16 @@
|
|||||||
<i class="bi bi-people"></i>Kontakter
|
<i class="bi bi-people"></i>Kontakter
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#invoices">
|
<a class="nav-link" data-bs-toggle="tab" href="#invoices">
|
||||||
<i class="bi bi-receipt"></i>Fakturaer
|
<i class="bi bi-receipt"></i>Fakturaer
|
||||||
@ -343,6 +380,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Invoices Tab -->
|
||||||
<div class="tab-pane fade" id="invoices">
|
<div class="tab-pane fade" id="invoices">
|
||||||
<h5 class="fw-bold mb-4">Fakturaer</h5>
|
<h5 class="fw-bold mb-4">Fakturaer</h5>
|
||||||
@ -832,6 +887,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -849,10 +925,12 @@ let kontaktHistoryItems = [];
|
|||||||
let kontaktHistoryFilter = 'all';
|
let kontaktHistoryFilter = 'all';
|
||||||
let mergeSourceContactId = null;
|
let mergeSourceContactId = null;
|
||||||
let mergeSearchTimer = null;
|
let mergeSearchTimer = null;
|
||||||
|
let pendingContactEmailSuggestions = [];
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadContact();
|
loadContact();
|
||||||
loadCompaniesForSelect();
|
loadCompaniesForSelect();
|
||||||
|
initializeContactEmailDropzone();
|
||||||
|
|
||||||
const mergeSearch = document.getElementById('mergeContactSearch');
|
const mergeSearch = document.getElementById('mergeContactSearch');
|
||||||
if (mergeSearch) {
|
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"]');
|
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
|
||||||
if (subscriptionsTab) {
|
if (subscriptionsTab) {
|
||||||
subscriptionsTab.addEventListener('shown.bs.tab', () => {
|
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() {
|
async function loadContact() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/contacts/${contactId}`);
|
const response = await fetch(`/api/v1/contacts/${contactId}`);
|
||||||
|
|||||||
@ -490,6 +490,24 @@
|
|||||||
padding: 0;
|
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) {
|
@media (max-width: 992px) {
|
||||||
.contacts-toolbar {
|
.contacts-toolbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -528,10 +546,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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()">
|
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
<div class="mb-4 d-flex gap-2 flex-wrap">
|
||||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
||||||
@ -632,6 +657,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Create Contact Modal -->
|
||||||
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
|
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
@ -819,6 +893,7 @@ let selectedCompanyIds = new Set();
|
|||||||
let currentContactsData = [];
|
let currentContactsData = [];
|
||||||
let pendingCreateModalCustomerId = null;
|
let pendingCreateModalCustomerId = null;
|
||||||
let pendingCreateReturnTo = null;
|
let pendingCreateReturnTo = null;
|
||||||
|
let analyzedMailCompany = null;
|
||||||
let currentSort = {
|
let currentSort = {
|
||||||
key: 'name',
|
key: 'name',
|
||||||
direction: 'asc'
|
direction: 'asc'
|
||||||
@ -832,6 +907,12 @@ let visibleColumns = {
|
|||||||
|
|
||||||
// Load contacts on page load
|
// Load contacts on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
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 urlParams = new URLSearchParams(window.location.search);
|
||||||
const preselectedCustomerId = Number(urlParams.get('customer_id'));
|
const preselectedCustomerId = Number(urlParams.get('customer_id'));
|
||||||
const shouldOpenCreateModal = urlParams.get('create') === '1';
|
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) {
|
function setFilter(filter) {
|
||||||
currentFilter = filter;
|
currentFilter = filter;
|
||||||
currentPage = 0;
|
currentPage = 0;
|
||||||
@ -1425,6 +1542,105 @@ function renderSelectedCompanies() {
|
|||||||
`).join('');
|
`).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() {
|
function showCreateContactModal() {
|
||||||
// Reset form
|
// Reset form
|
||||||
document.getElementById('createContactForm').reset();
|
document.getElementById('createContactForm').reset();
|
||||||
|
|||||||
@ -15,6 +15,21 @@ class Settings(BaseSettings):
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL: str = "postgresql://bmc_hub:bmc_hub@localhost:5432/bmc_hub"
|
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
|
||||||
API_HOST: str = "0.0.0.0"
|
API_HOST: str = "0.0.0.0"
|
||||||
API_PORT: int = 8000
|
API_PORT: int = 8000
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from pydantic import BaseModel
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
|
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
|
is_active: Optional[bool] = True
|
||||||
invoice_email: Optional[str] = None
|
invoice_email: Optional[str] = None
|
||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
|
supplier_service_enrolled: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(CustomerBase):
|
class CustomerCreate(CustomerBase):
|
||||||
@ -133,6 +135,7 @@ class CustomerUpdate(BaseModel):
|
|||||||
invoice_email: Optional[str] = None
|
invoice_email: Optional[str] = None
|
||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
department: Optional[str] = None
|
department: Optional[str] = None
|
||||||
|
supplier_service_enrolled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class EmailDomainCreate(BaseModel):
|
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")
|
@router.get("/customers/{customer_id}/utility-company")
|
||||||
async def get_customer_utility_company(customer_id: int):
|
async def get_customer_utility_company(customer_id: int):
|
||||||
@ -739,8 +812,8 @@ async def create_customer(customer: CustomerCreate):
|
|||||||
customer_id = execute_insert(
|
customer_id = execute_insert(
|
||||||
"""INSERT INTO customers
|
"""INSERT INTO customers
|
||||||
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
|
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
|
||||||
country, website, is_active, invoice_email, mobile_phone)
|
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)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
customer.name,
|
customer.name,
|
||||||
@ -755,7 +828,8 @@ async def create_customer(customer: CustomerCreate):
|
|||||||
customer.website,
|
customer.website,
|
||||||
customer.is_active,
|
customer.is_active,
|
||||||
customer.invoice_email,
|
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
|
<i class="bi bi-list-check"></i>Sager
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
||||||
<i class="bi bi-chat-left-text"></i>Kontakt
|
<i class="bi bi-chat-left-text"></i>Kontakt
|
||||||
@ -1047,6 +1052,13 @@
|
|||||||
<span class="info-label">Spærret</span>
|
<span class="info-label">Spærret</span>
|
||||||
<span class="info-value" id="barred">-</span>
|
<span class="info-value" id="barred">-</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1216,6 +1228,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Kontakt Tab -->
|
||||||
<div class="tab-pane fade" id="kontakt">
|
<div class="tab-pane fade" id="kontakt">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -2296,6 +2321,8 @@ let customerKontaktFilter = 'all';
|
|||||||
let customerInvoicesLoaded = false;
|
let customerInvoicesLoaded = false;
|
||||||
let customerInvoicesData = [];
|
let customerInvoicesData = [];
|
||||||
let customerInvoiceSearchTerm = '';
|
let customerInvoiceSearchTerm = '';
|
||||||
|
let customerEmailsOffset = 0;
|
||||||
|
const customerEmailsLimit = 25;
|
||||||
|
|
||||||
let eventListenersAdded = false;
|
let eventListenersAdded = false;
|
||||||
|
|
||||||
@ -2327,6 +2354,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { 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"]');
|
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
||||||
if (kontaktTab) {
|
if (kontaktTab) {
|
||||||
kontaktTab.addEventListener('shown.bs.tab', () => {
|
kontaktTab.addEventListener('shown.bs.tab', () => {
|
||||||
@ -2730,6 +2762,7 @@ function displayCustomer(customer) {
|
|||||||
document.getElementById('barred').innerHTML = customer.barred
|
document.getElementById('barred').innerHTML = customer.barred
|
||||||
? '<span class="badge bg-danger">Ja</span>'
|
? '<span class="badge bg-danger">Ja</span>'
|
||||||
: '<span class="badge bg-success">Nej</span>';
|
: '<span class="badge bg-success">Nej</span>';
|
||||||
|
document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true;
|
||||||
|
|
||||||
// Integration
|
// Integration
|
||||||
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
|
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
|
||||||
@ -2745,6 +2778,68 @@ function displayCustomer(customer) {
|
|||||||
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container');
|
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) {
|
function renderCustomerCallNumber(number) {
|
||||||
const clean = normalizePhoneValue(number);
|
const clean = normalizePhoneValue(number);
|
||||||
if (!clean) return '-';
|
if (!clean) return '-';
|
||||||
|
|||||||
@ -11,6 +11,11 @@ import json
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from app.core.database import execute_query, get_db_connection
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -26,6 +31,13 @@ async def process_subscriptions():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("💰 Processing subscription invoices...")
|
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
|
# Find subscriptions due for invoicing
|
||||||
query = """
|
query = """
|
||||||
@ -37,8 +49,13 @@ async def process_subscriptions():
|
|||||||
c.name AS customer_name,
|
c.name AS customer_name,
|
||||||
s.product_name,
|
s.product_name,
|
||||||
s.billing_interval,
|
s.billing_interval,
|
||||||
|
s.billing_schedule_type,
|
||||||
|
s.billing_day,
|
||||||
s.billing_direction,
|
s.billing_direction,
|
||||||
s.advance_months,
|
s.advance_months,
|
||||||
|
s.billing_lead_months,
|
||||||
|
s.first_full_period_start,
|
||||||
|
s.proration_basis,
|
||||||
s.price,
|
s.price,
|
||||||
s.next_invoice_date,
|
s.next_invoice_date,
|
||||||
s.period_start,
|
s.period_start,
|
||||||
@ -67,11 +84,30 @@ async def process_subscriptions():
|
|||||||
),
|
),
|
||||||
'[]'::json
|
'[]'::json
|
||||||
) as line_items
|
) 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
|
FROM sag_subscriptions s
|
||||||
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
WHERE s.status = 'active'
|
WHERE s.status = 'active'
|
||||||
AND s.next_invoice_date <= CURRENT_DATE
|
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
|
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_start = None
|
||||||
coverage_end = 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:
|
for sub in subscriptions:
|
||||||
subscription_id = int(sub['id'])
|
subscription_id = int(sub['id'])
|
||||||
source_subscription_ids.append(subscription_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)
|
line_items = json.loads(line_items)
|
||||||
|
|
||||||
period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
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:
|
if coverage_start is None or period_start < coverage_start:
|
||||||
coverage_start = period_start
|
coverage_start = period_start
|
||||||
if coverage_end is None or period_end > coverage_end:
|
if coverage_end is None or period_end > coverage_end:
|
||||||
@ -171,9 +229,53 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
product_number = str(item.get('product_id', 'SUB'))
|
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({
|
ordre_lines.append({
|
||||||
"product": {
|
"product": {
|
||||||
"productNumber": product_number,
|
"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', '')
|
"description": item.get('description', '')
|
||||||
},
|
},
|
||||||
"quantity": float(item.get('quantity', 1)),
|
"quantity": float(item.get('quantity', 1)),
|
||||||
@ -182,9 +284,8 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
|||||||
"discountPercentage": 0,
|
"discountPercentage": 0,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"subscription_id": subscription_id,
|
"subscription_id": subscription_id,
|
||||||
"asset_id": item.get('asset_id'),
|
"first_invoice_item_id": item.get('id'),
|
||||||
"period_from": str(item.get('period_from') or period_start),
|
"one_time": True,
|
||||||
"period_to": str(item.get('period_to') or period_end),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -237,6 +338,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
|||||||
))
|
))
|
||||||
|
|
||||||
ordre_id = cursor.fetchone()[0]
|
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(
|
logger.info(
|
||||||
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
|
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
|
||||||
ordre_id,
|
ordre_id,
|
||||||
@ -246,8 +363,19 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
|||||||
for sub in subscriptions:
|
for sub in subscriptions:
|
||||||
subscription_id = int(sub['id'])
|
subscription_id = int(sub['id'])
|
||||||
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
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'])
|
first_full_period_start = sub.get('first_full_period_start')
|
||||||
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
|
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(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1002,7 +1002,9 @@ async def create_sag(request: Request, data: dict):
|
|||||||
status = _normalize_case_status(data.get("status"))
|
status = _normalize_case_status(data.get("status"))
|
||||||
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
||||||
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
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")
|
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
||||||
|
|
||||||
_validate_user_id(ansvarlig_bruger_id)
|
_validate_user_id(ansvarlig_bruger_id)
|
||||||
@ -1089,7 +1091,7 @@ async def create_sag(request: Request, data: dict):
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
(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"]),
|
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
||||||
)
|
)
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
|
|||||||
@ -1234,11 +1234,28 @@
|
|||||||
updateCaseTypeSections();
|
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 ---
|
// --- Initialization ---
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initializeSearch();
|
initializeSearch();
|
||||||
loadCaseTypesSelect();
|
loadCaseTypesSelect();
|
||||||
loadPipelineStages();
|
loadPipelineStages();
|
||||||
|
selectCurrentUserAsResponsible();
|
||||||
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
||||||
applyTelefoniPrefill();
|
applyTelefoniPrefill();
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,8 @@ import logging
|
|||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.error import URLError, HTTPError
|
from urllib.error import URLError, HTTPError
|
||||||
@ -441,7 +443,8 @@ async def yealink_terminated(
|
|||||||
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
|
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
|
||||||
if not updated:
|
if not updated:
|
||||||
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
|
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")
|
@router.websocket("/telefoni/ws")
|
||||||
@ -617,6 +620,76 @@ def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]
|
|||||||
return str(value)
|
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")
|
@router.post("/telefoni/click-to-call")
|
||||||
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
||||||
client_ip = _get_client_ip(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")
|
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
|
||||||
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
|
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 "-")
|
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -728,11 +808,29 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
|||||||
if auth_header:
|
if auth_header:
|
||||||
display_url = "[basic-auth] " + resolved_url
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"action_url": display_url,
|
"action_url": display_url,
|
||||||
"http_status": status,
|
"http_status": status,
|
||||||
"number": number_normalized,
|
"number": number_normalized,
|
||||||
|
"call_record": call_record,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,8 @@ class TelefoniClickToCallRequest(BaseModel):
|
|||||||
number: str
|
number: str
|
||||||
extension: Optional[str] = None
|
extension: Optional[str] = None
|
||||||
user_id: Optional[int] = None
|
user_id: Optional[int] = None
|
||||||
|
sag_id: Optional[int] = None
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class SmsSendRequest(BaseModel):
|
class SmsSendRequest(BaseModel):
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@ -122,6 +123,52 @@ class TelefoniService:
|
|||||||
raw_payload: Any,
|
raw_payload: Any,
|
||||||
started_at: datetime,
|
started_at: datetime,
|
||||||
) -> dict:
|
) -> 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 = """
|
query = """
|
||||||
INSERT INTO telefoni_opkald
|
INSERT INTO telefoni_opkald
|
||||||
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)
|
(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><hr class="dropdown-divider"></li>
|
||||||
<li><h6 class="dropdown-header">Værktøjer</h6></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-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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" data-menu-key="menu-salg">
|
<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-hardware-customers', label: 'Support: Kundehardware' },
|
||||||
{ key: 'menu-support-eset', label: 'Support: ESET Oversigt' },
|
{ key: 'menu-support-eset', label: 'Support: ESET Oversigt' },
|
||||||
{ key: 'menu-support-manual', label: 'Support: Manualer' },
|
{ 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-orders', label: 'Salg: Ordre' },
|
||||||
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
|
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
|
||||||
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
|
{ 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 title %}Abonnementer - BMC Hub{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-4">
|
<style>
|
||||||
<div class="row mb-4">
|
.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}
|
||||||
<div class="col">
|
.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)}
|
||||||
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
.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}
|
||||||
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
.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>
|
||||||
<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>
|
||||||
</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="row g-3 mb-4" id="statsCards">
|
||||||
<div class="col-md-4">
|
<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="card border-0 shadow-sm">
|
<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="card-body">
|
<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>
|
||||||
<p class="text-muted small mb-1">Aktive Abonnementer</p>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="sub-toolbar mb-3">
|
||||||
<div class="card-header bg-white border-0 py-3">
|
<div class="row align-items-center">
|
||||||
<h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
|
<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>
|
||||||
<div class="card-body p-0">
|
</div>
|
||||||
<div class="table-responsive">
|
<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>
|
||||||
<table class="table table-hover align-middle mb-0">
|
<div class="sub-table-shell table-responsive">
|
||||||
<thead class="bg-light">
|
<table class="table sub-table align-middle">
|
||||||
<tr>
|
<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>
|
||||||
<th>Abonnement</th>
|
<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>
|
||||||
<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>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -88,7 +87,7 @@
|
|||||||
<div class="modal-dialog modal-xl">
|
<div class="modal-dialog modal-xl">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@ -103,14 +102,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Pris (DKK)</label>
|
<label class="form-label">Ny total <span class="text-muted small">(beregnes fra linjer)</span></label>
|
||||||
<input type="number" class="form-control" id="editPrice" step="0.01" min="0">
|
<input type="number" class="form-control bg-light" id="editPrice" step="0.01" min="0" readonly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Billing interval</label>
|
<label class="form-label">Billing interval</label>
|
||||||
<select class="form-select" id="editInterval">
|
<select class="form-select" id="editInterval">
|
||||||
@ -122,10 +121,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Billing dag (1-31)</label>
|
<label class="form-label">Faktureringsregel</label>
|
||||||
<input type="number" class="form-control" id="editBillingDay" min="1" max="31">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -149,13 +156,13 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Periode start <i class="bi bi-info-circle" title="Startdato for nuværende faktureringsperiode"></i></label>
|
<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>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -172,9 +179,13 @@
|
|||||||
<label class="form-label">Status</label>
|
<label class="form-label">Status</label>
|
||||||
<select class="form-select" id="editStatus">
|
<select class="form-select" id="editStatus">
|
||||||
<option value="draft">Kladde</option>
|
<option value="draft">Kladde</option>
|
||||||
|
<option value="scheduled">Planlagt</option>
|
||||||
<option value="active">Aktiv</option>
|
<option value="active">Aktiv</option>
|
||||||
<option value="paused">Pauset</option>
|
<option value="paused">Pauset</option>
|
||||||
|
<option value="terminating">Under opsigelse</option>
|
||||||
<option value="cancelled">Opsagt</option>
|
<option value="cancelled">Opsagt</option>
|
||||||
|
<option value="expired">Udløbet</option>
|
||||||
|
<option value="blocked">Blokeret</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -185,6 +196,10 @@
|
|||||||
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
||||||
</div>
|
</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">
|
<hr class="my-4">
|
||||||
|
|
||||||
<!-- Line Items Section -->
|
<!-- Line Items Section -->
|
||||||
@ -223,7 +238,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -263,6 +278,9 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentSubscriptions = [];
|
let currentSubscriptions = [];
|
||||||
let currentStagingCustomerKey = null;
|
let currentStagingCustomerKey = null;
|
||||||
|
let currentSubscriptionStatus = 'all';
|
||||||
|
let currentBillingCalendarData = null;
|
||||||
|
let billingCalendarLoaded = false;
|
||||||
|
|
||||||
function stagingStatusBadge(status) {
|
function stagingStatusBadge(status) {
|
||||||
const badges = {
|
const badges = {
|
||||||
@ -513,93 +531,201 @@ async function approveSelectedStagingRows() {
|
|||||||
|
|
||||||
async function loadSubscriptions() {
|
async function loadSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
|
const response = await fetch('/api/v1/sag-subscriptions?status=all');
|
||||||
const stats = await fetch(`/api/v1/sag-subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
|
const subscriptions = await response.json();
|
||||||
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
|
if (!response.ok) throw new Error(subscriptions.detail || 'Kunne ikke hente abonnementer');
|
||||||
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());
|
|
||||||
currentSubscriptions = subscriptions;
|
currentSubscriptions = subscriptions;
|
||||||
renderSubscriptions(subscriptions);
|
updateSubscriptionMetrics(subscriptions);
|
||||||
|
applySubscriptionFilters();
|
||||||
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';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading subscriptions:', e);
|
console.error('Error loading subscriptions:', e);
|
||||||
document.getElementById('subscriptionsBody').innerHTML = `
|
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>`;
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function renderSubscriptions(subscriptions) {
|
||||||
const tbody = document.getElementById('subscriptionsBody');
|
const grid = document.getElementById('subscriptionsGrid');
|
||||||
|
|
||||||
if (!subscriptions || subscriptions.length === 0) {
|
if (!subscriptions || subscriptions.length === 0) {
|
||||||
tbody.innerHTML = `
|
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>`;
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = subscriptions.map(sub => {
|
grid.innerHTML = subscriptions.map(sub => {
|
||||||
const intervalLabel = formatInterval(sub.billing_interval);
|
const intervalLabel = formatInterval(sub.billing_interval);
|
||||||
const statusBadge = getStatusBadge(sub.status);
|
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 sagLink = sub.sag_id ? `/sag/${sub.sag_id}/v3?tab=subscription` : '#';
|
||||||
const subNumber = sub.subscription_number || `#${sub.id}`;
|
const subNumber = escapeHtml(sub.subscription_number || `#${sub.id}`);
|
||||||
|
const schedule = billingScheduleLabel(sub);
|
||||||
// Show product name with item count if available
|
const nextInvoice = sub.next_invoice_date ? formatDate(sub.next_invoice_date) : 'Ikke planlagt';
|
||||||
let productDisplay = sub.product_name || '-';
|
const rowClass = sub.billing_blocked || sub.status === 'blocked' ? 'is-blocked' : ['terminating','cancelled','expired'].includes(sub.status) ? 'is-ending' : '';
|
||||||
if (sub.line_items && sub.line_items.length > 0) {
|
const period = [sub.period_start || sub.start_date, sub.end_date].filter(Boolean).map(formatDate);
|
||||||
productDisplay = `${sub.product_name} <span class="badge bg-light text-dark">${sub.line_items.length} varer</span>`;
|
const productLines = (sub.product_lines || []).slice(0, 3);
|
||||||
}
|
const remainingLines = Math.max(0, Number(sub.item_count || 0) - productLines.length);
|
||||||
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr class="${rowClass}">
|
||||||
<td><strong>${subNumber}</strong></td>
|
<td><a href="${sagLink}" class="sub-table-primary text-decoration-none">${subNumber}</a><div class="sub-table-secondary">ID ${sub.id}</div></td>
|
||||||
<td>${sub.customer_name || '-'}</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>${sagLink}</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>${productDisplay}</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>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
|
<td><div>${escapeHtml(period[0] || '–')}</div><div class="sub-table-secondary">${period[1] ? `til ${escapeHtml(period[1])}` : 'Ingen slutdato'}</div></td>
|
||||||
<td>${formatCurrency(sub.price || 0)}</td>
|
<td><div class="sub-table-primary">${escapeHtml(intervalLabel)}</div><div class="sub-table-secondary">${escapeHtml(schedule)}</div></td>
|
||||||
<td>${formatDate(sub.start_date)}</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>${statusBadge}</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>${actions}</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>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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) {
|
async function openEditModal(subId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`);
|
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('editProductName').value = sub.product_name || '';
|
||||||
document.getElementById('editPrice').value = sub.price || 0;
|
document.getElementById('editPrice').value = sub.price || 0;
|
||||||
document.getElementById('editInterval').value = sub.billing_interval || 'monthly';
|
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('editBillingDay').value = sub.billing_day || 1;
|
||||||
document.getElementById('editStartDate').value = sub.start_date || '';
|
document.getElementById('editStartDate').value = sub.start_date || '';
|
||||||
document.getElementById('editEndDate').value = sub.end_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('editNoticePeriod').value = sub.notice_period_days || 30;
|
||||||
document.getElementById('editStatus').value = sub.status || 'draft';
|
document.getElementById('editStatus').value = sub.status || 'draft';
|
||||||
document.getElementById('editNotes').value = sub.notes || '';
|
document.getElementById('editNotes').value = sub.notes || '';
|
||||||
|
document.getElementById('editChangeReason').value = '';
|
||||||
|
document.getElementById('editEffectiveDate').value = new Date().toISOString().slice(0, 10);
|
||||||
|
syncEditScheduleControls();
|
||||||
|
|
||||||
// Load line items
|
// Load line items
|
||||||
renderLineItems(sub.line_items || []);
|
renderLineItems(sub.line_items || []);
|
||||||
@ -727,6 +857,7 @@ function calculateTotal() {
|
|||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
try {
|
try {
|
||||||
const subId = document.getElementById('editSubId').value;
|
const subId = document.getElementById('editSubId').value;
|
||||||
|
const currentSub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
|
||||||
|
|
||||||
// Validate line items
|
// Validate line items
|
||||||
const validLineItems = lineItemsData.filter(item => {
|
const validLineItems = lineItemsData.filter(item => {
|
||||||
@ -741,23 +872,29 @@ async function saveEdit() {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
product_name: document.getElementById('editProductName').value,
|
product_name: document.getElementById('editProductName').value,
|
||||||
price: parseFloat(document.getElementById('editPrice').value),
|
|
||||||
billing_interval: document.getElementById('editInterval').value,
|
billing_interval: document.getElementById('editInterval').value,
|
||||||
|
billing_schedule_type: document.getElementById('editScheduleType').value,
|
||||||
billing_day: parseInt(document.getElementById('editBillingDay').value),
|
billing_day: parseInt(document.getElementById('editBillingDay').value),
|
||||||
start_date: document.getElementById('editStartDate').value || null,
|
start_date: document.getElementById('editStartDate').value || null,
|
||||||
end_date: document.getElementById('editEndDate').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),
|
notice_period_days: parseInt(document.getElementById('editNoticePeriod').value),
|
||||||
status: document.getElementById('editStatus').value,
|
status: document.getElementById('editStatus').value,
|
||||||
notes: document.getElementById('editNotes').value,
|
notes: document.getElementById('editNotes').value,
|
||||||
line_items: validLineItems
|
line_items: validLineItems
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`, {
|
const reason = document.getElementById('editChangeReason').value.trim();
|
||||||
method: 'PATCH',
|
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' },
|
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) {
|
if (!response.ok) {
|
||||||
@ -765,9 +902,9 @@ async function saveEdit() {
|
|||||||
throw new Error(error.detail || 'Failed to update');
|
throw new Error(error.detail || 'Failed to update');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||||
loadSubscriptions();
|
window.location.href = result.case_url;
|
||||||
alert('✅ Abonnement opdateret');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('❌ Fejl ved opdatering: ' + e.message);
|
alert('❌ Fejl ved opdatering: ' + e.message);
|
||||||
}
|
}
|
||||||
@ -797,20 +934,27 @@ async function confirmCancel() {
|
|||||||
try {
|
try {
|
||||||
const subId = document.getElementById('cancelSubId').value;
|
const subId = document.getElementById('cancelSubId').value;
|
||||||
const reason = document.getElementById('cancelReason').value;
|
const reason = document.getElementById('cancelReason').value;
|
||||||
|
const sub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
|
||||||
const response = await fetch(`/api/v1/subscriptions/${subId}/cancel`, {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!response.ok) throw new Error('Failed to cancel');
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
|
||||||
loadSubscriptions();
|
window.location.href = result.case_url;
|
||||||
|
|
||||||
alert(`✅ Abonnement opsagt\nSlutdato: ${new Date(result.end_date).toLocaleDateString('da-DK')}\nSag oprettet: #${result.cancellation_case_id}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('❌ Fejl ved opsigelse: ' + e.message);
|
alert('❌ Fejl ved opsigelse: ' + e.message);
|
||||||
}
|
}
|
||||||
@ -829,14 +973,32 @@ function formatInterval(interval) {
|
|||||||
|
|
||||||
function getStatusBadge(status) {
|
function getStatusBadge(status) {
|
||||||
const badges = {
|
const badges = {
|
||||||
'active': '<span class="badge bg-success">Aktiv</span>',
|
'active': '<span class="badge rounded-pill bg-success-subtle text-success border border-success-subtle">● Aktiv</span>',
|
||||||
'paused': '<span class="badge bg-warning">Pauset</span>',
|
'scheduled': '<span class="badge rounded-pill bg-info-subtle text-info-emphasis">Planlagt</span>',
|
||||||
'cancelled': '<span class="badge bg-secondary">Opsagt</span>',
|
'paused': '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Pauset</span>',
|
||||||
'draft': '<span class="badge bg-light text-dark">Kladde</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 || '-';
|
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) {
|
function formatCurrency(amount) {
|
||||||
return new Intl.NumberFormat('da-DK', {
|
return new Intl.NumberFormat('da-DK', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@ -853,10 +1015,22 @@ function formatDate(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const filter = document.getElementById('subscriptionStatusFilter');
|
document.getElementById('subscriptionSearch')?.addEventListener('input', applySubscriptionFilters);
|
||||||
if (filter) {
|
document.querySelectorAll('.sub-filter-pill').forEach(button => button.addEventListener('click', () => {
|
||||||
filter.addEventListener('change', loadSubscriptions);
|
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();
|
loadSubscriptions();
|
||||||
});
|
});
|
||||||
</script>
|
</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.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.backend import router as migration_center_api
|
||||||
from app.modules.migration_center.frontend import views as migration_center_views
|
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
|
from app.bug_reports.backend import router as bug_reports_api
|
||||||
|
|
||||||
# Configure logging
|
# 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(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(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(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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
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(internet_connections_views.router, tags=["Frontend"])
|
||||||
app.include_router(invoice_error_finder_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(migration_center_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(website_content_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
if settings.LINKS_MODULE_ENABLED:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.frontend import views as links_views
|
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
|
pydantic-settings==2.6.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
python-multipart==0.0.17
|
python-multipart==0.0.17
|
||||||
|
extract-msg==0.56.1
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
aiohttp==3.10.10
|
aiohttp==3.10.10
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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[0][1] == 10
|
||||||
assert company_link_params[1][1] == 20
|
assert company_link_params[1][1] == 20
|
||||||
assert update_calls
|
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
|
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():
|
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
|
||||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
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 TelefoniService.terminate_call("stale-call", None) is True
|
||||||
assert "INTERVAL '12 hours'" in queries[0]
|
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