From ffdc9ac62c2d51e6b1f7dd3beaa156a79d249efb Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 20:49:55 +0200 Subject: [PATCH] release: v2.7.2 --- .env.example | 6 + MDfile/RELEASE_NOTES_v2.7.2.md | 30 + VERSION | 2 +- app/contacts/backend/router_simple.py | 405 +++++++- app/contacts/frontend/contact_detail.html | 225 +++++ app/contacts/frontend/contacts.html | 222 +++- app/core/config.py | 15 + app/customers/backend/router.py | 80 +- app/customers/frontend/customer_detail.html | 95 ++ app/jobs/process_subscriptions.py | 140 ++- app/modules/sag/backend/router.py | 6 +- app/modules/sag/templates/create.html | 17 + app/modules/sag/templates/detail_v3.html | 900 ++++++++++++++++- app/modules/telefoni/backend/router.py | 100 +- app/modules/telefoni/backend/schemas.py | 2 + app/modules/telefoni/backend/service.py | 47 + app/modules/website_content/__init__.py | 1 + .../website_content/backend/__init__.py | 1 + app/modules/website_content/backend/router.py | 152 +++ .../website_content/backend/schemas.py | 69 ++ .../website_content/backend/service.py | 99 ++ .../website_content/frontend/__init__.py | 1 + app/modules/website_content/frontend/views.py | 19 + app/modules/website_content/module.json | 11 + .../website_content/templates/index.html | 127 +++ app/services/subscription_agreement.py | 28 + app/services/subscription_billing_calendar.py | 132 +++ app/shared/frontend/base.html | 2 + app/subscriptions/backend/router.py | 945 ++++++++++++++++-- app/subscriptions/frontend/list.html | 510 ++++++---- deploy/website/admin-content.php | 344 +++++++ deploy/website/content.php | 163 +++ main.py | 4 + .../1019_subscription_change_workflow.sql | 123 +++ .../1020_subscription_first_invoice_items.sql | 20 + ...cription_advance_billing_and_proration.sql | 11 + .../1022_subscription_schedule_integrity.sql | 31 + .../232_website_content_permissions.sql | 18 + requirements.txt | 1 + tests/test_contacts_router_simple.py | 160 +++ tests/test_customer_crm_improvements.py | 23 + tests/test_sag_module.py | 18 + tests/test_subscription_billing_calendar.py | 75 ++ tests/test_telefoni_call_logging.py | 13 + tests/test_website_content.py | 60 ++ 45 files changed, 5150 insertions(+), 303 deletions(-) create mode 100644 MDfile/RELEASE_NOTES_v2.7.2.md create mode 100644 app/modules/website_content/__init__.py create mode 100644 app/modules/website_content/backend/__init__.py create mode 100644 app/modules/website_content/backend/router.py create mode 100644 app/modules/website_content/backend/schemas.py create mode 100644 app/modules/website_content/backend/service.py create mode 100644 app/modules/website_content/frontend/__init__.py create mode 100644 app/modules/website_content/frontend/views.py create mode 100644 app/modules/website_content/module.json create mode 100644 app/modules/website_content/templates/index.html create mode 100644 app/services/subscription_agreement.py create mode 100644 app/services/subscription_billing_calendar.py create mode 100644 deploy/website/admin-content.php create mode 100644 deploy/website/content.php create mode 100644 migrations/1019_subscription_change_workflow.sql create mode 100644 migrations/1020_subscription_first_invoice_items.sql create mode 100644 migrations/1021_subscription_advance_billing_and_proration.sql create mode 100644 migrations/1022_subscription_schedule_integrity.sql create mode 100644 migrations/232_website_content_permissions.sql create mode 100644 tests/test_customer_crm_improvements.py create mode 100644 tests/test_subscription_billing_calendar.py create mode 100644 tests/test_website_content.py diff --git a/.env.example b/.env.example index a27c186..a9926e3 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ POSTGRES_PASSWORD=bmc_hub POSTGRES_DB=bmc_hub POSTGRES_PORT=5433 # Changed from 5432 to avoid conflicts with other services +# Public website content administration API +WEBSITE_CONTENT_API_URL=https://isp.bmcnetworks.dk/api/admin-content.php +WEBSITE_CONTENT_API_HOST_HEADER=ct.bmcnetworks.dk +WEBSITE_CONTENT_API_TOKEN= +WEBSITE_CONTENT_API_TIMEOUT_SECONDS=20 + # ===================================================== # API CONFIGURATION # ===================================================== diff --git a/MDfile/RELEASE_NOTES_v2.7.2.md b/MDfile/RELEASE_NOTES_v2.7.2.md new file mode 100644 index 0000000..fd7ac85 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.7.2.md @@ -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. diff --git a/VERSION b/VERSION index 860487c..37c2961 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.1 +2.7.2 diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index 6faf327..69912dc 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -3,8 +3,8 @@ Contact API Router - Simplified (Read-Only) Only GET endpoints for now """ -from fastapi import APIRouter, HTTPException, Query, Body, status -from typing import Optional +from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File +from typing import Any, Optional from pydantic import BaseModel, Field from app.core.database import ( execute_query, @@ -15,6 +15,7 @@ from app.core.database import ( ) from psycopg2.extras import RealDictCursor from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id +from app.services.cvr_service import get_cvr_service from app.customers.backend.router import ( get_customer_subscriptions, lock_customer_subscriptions, @@ -22,9 +23,14 @@ from app.customers.backend.router import ( get_subscription_comment, get_subscription_billing_matrix, SubscriptionComment, + CustomerCreate, + create_customer, ) import logging import json +import re +import html +from email.utils import parseaddr logger = logging.getLogger(__name__) router = APIRouter() @@ -63,6 +69,17 @@ class ContactMergeRequest(BaseModel): source_contact_id: int = Field(..., gt=0) +class EmailCompanyResolve(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + cvr_number: str = Field(..., pattern=r"^\d{8}$") + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + website: Optional[str] = None + + CONTACT_MERGE_RELATIONS = ( ("Firmaer", "contact_companies", "contact_id"), ("Sager", "sag_kontakter", "contact_id"), @@ -79,6 +96,325 @@ CONTACT_MERGE_RELATIONS = ( ) +def _exact_email_pattern(address: str) -> str: + return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)" + + +def _plain_email_body(parsed: dict) -> str: + text = str(parsed.get("body_text") or "").strip() + if text: + return html.unescape(text).replace("\xa0", " ") + raw_html = str(parsed.get("body_html") or "") + raw_html = re.sub(r"(?is)<(script|style).*?>.*?", " ", raw_html) + raw_html = re.sub(r"(?i)|

|", "\n", raw_html) + return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw_html)).replace("\xa0", " ") + + +def _signature_lines(body: str) -> list[str]: + """Normalize common Outlook/Markdown decoration without losing line structure.""" + lines = [] + for raw_line in body.splitlines(): + line = re.sub(r"[*_`]+", "", raw_line) + line = re.sub(r"\s+", " ", line).strip(" \\|") + if line: + lines.append(line) + return lines + + +def _labelled_phone(lines: list[str], labels: str) -> Optional[str]: + phone_pattern = re.compile(r"\+?\d(?:[\s().-]*\d){5,14}") + label_pattern = re.compile(rf"(?i)^\s*(?:{labels})\s*:\s*(.*)$") + for line in lines: + labelled = label_pattern.search(line) + if labelled: + phone = phone_pattern.search(labelled.group(1)) + if phone: + return phone.group(0).strip() + return None + + +def _contact_suggestions_from_email(contact: dict, parsed: dict) -> list[dict]: + """Conservative, review-only extraction from headers and labelled signature lines.""" + body = _plain_email_body(parsed) + signature_lines = _signature_lines(body) + sender_email = str(parsed.get("sender_email") or "").strip().lower() + sender_name = str(parsed.get("sender_name") or "").strip() + parsed_sender_name, parsed_sender_address = parseaddr(sender_name) + if parsed_sender_name: + sender_name = parsed_sender_name + if not sender_email and parsed_sender_address: + sender_email = parsed_sender_address.strip().lower() + current_email = str(contact.get("email") or "").strip().lower() + candidates: dict[str, tuple[str, str]] = {} + + # A received Outlook message normally represents the sender. If this looks + # like a sent message to the current contact, do not suggest our own sender. + recipient_text = str(parsed.get("recipient_email") or "").lower() + sender_represents_contact = not current_email or current_email == sender_email or current_email not in recipient_text + if sender_represents_contact and sender_email: + candidates["email"] = (sender_email, "Mailens afsender") + if sender_represents_contact and sender_name and "@" not in sender_name: + name_parts = sender_name.split() + if name_parts: + candidates["first_name"] = (name_parts[0], "Afsendernavn") + if len(name_parts) > 1: + candidates["last_name"] = (" ".join(name_parts[1:]), "Afsendernavn") + + mobile = _labelled_phone(signature_lines, r"mobil|mobile|mob\.?") + phone = _labelled_phone(signature_lines, r"telefon|phone|tel\.?|direkte") + if mobile: + candidates["mobile"] = (mobile, "Mailens signatur") + if phone: + candidates["phone"] = (phone, "Mailens signatur") + + for field, labels_pattern in (("title", r"titel|stilling|job title"), ("department", r"afdeling|department")): + pattern = re.compile(rf"(?i)^\s*(?:{labels_pattern})\s*:\s*(.{{2,100}}?)\s*$") + for line in signature_lines: + match = pattern.search(line) + if match: + candidates[field] = (match.group(1).strip(), "Mailens signatur") + break + + # Outlook signatures often put an unlabelled job title directly below the + # sender's name. This remains a review-only suggestion. + contact_full_name = " ".join( + part for part in (str(contact.get("first_name") or "").strip(), str(contact.get("last_name") or "").strip()) if part + ) + signature_names = [name.casefold() for name in (contact_full_name, sender_name) if len(name.split()) >= 2] + rejected_titles = { + "kind regards", "best regards", "regards", "med venlig hilsen", + "venlig hilsen", "mvh", "thanks", "thank you", "tak", + } + for signature_name in dict.fromkeys(signature_names): + for index in range(len(signature_lines) - 2, -1, -1): + if signature_lines[index].casefold() != signature_name: + continue + possible_title = signature_lines[index + 1] + if ( + "title" not in candidates + and 2 <= len(possible_title) <= 100 + and possible_title.casefold().rstrip(",.! ") not in rejected_titles + and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|e-mail|web|www)\s*:", possible_title) + and not re.search(r"[@\d]|https?://", possible_title) + ): + candidates["title"] = (possible_title, "Linjen under afsendernavnet") + break + if "title" in candidates: + break + + labels = { + "first_name": "Fornavn", "last_name": "Efternavn", "email": "E-mail", + "phone": "Telefon", "mobile": "Mobil", "title": "Titel", "department": "Afdeling", + } + suggestions = [] + for field, (value, source) in candidates.items(): + current = str(contact.get(field) or "").strip() + if value and current.casefold() != value.casefold(): + suggestions.append({"field": field, "label": labels[field], "current": current or None, "suggested": value, "source": source}) + return suggestions + + +def _company_from_email_body(body: str, contact_name: str = "") -> dict: + lines = _signature_lines(body) + joined = "\n".join(lines) + cvr_match = re.search(r"(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d[\d .-]{6,12}\d)", joined) + cvr_number = re.sub(r"\D", "", cvr_match.group(1)) if cvr_match else "" + if len(cvr_number) != 8: + cvr_number = "" + + company_name = "" + labelled_name = re.search(r"(?im)^\s*(?:firma|company|virksomhed)\s*:\s*(.{2,100})$", joined) + if labelled_name: + company_name = labelled_name.group(1).strip() + if not company_name: + for index, line in enumerate(lines): + if not (re.search(r"\b\d{4}\s+[A-Za-zÆØÅæøå]", line) or re.search(r"[A-Za-zÆØÅæøå]{3,}\s+\d+[A-Za-z]?\b", line)): + continue + if index: + candidate = lines[index - 1].strip() + if ( + candidate.casefold() != contact_name.casefold() + and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|web|cvr)\s*:", candidate) + and not re.search(r"[@+]|https?://", candidate) + ): + company_name = candidate + break + return {"name": company_name or None, "cvr_number": cvr_number or None} + + +@router.post("/contacts/analyze-email") +async def analyze_email_for_new_contact(file: UploadFile = File(...)): + """Return editable contact/company candidates without creating anything.""" + filename = str(file.filename or "").lower() + if not filename.endswith((".eml", ".msg")): + raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes") + content = await file.read() + if not content or len(content) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB") + from app.services.email_service import EmailService + parser = EmailService() + parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content) + if not parsed: + raise HTTPException(status_code=422, detail="Mailen kunne ikke læses") + + empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")} + suggestions = _contact_suggestions_from_email(empty_contact, parsed) + contact = {item["field"]: item["suggested"] for item in suggestions} + contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name")))) + company = _company_from_email_body(_plain_email_body(parsed), contact_name) + company["existing_id"] = None + company["existing_name"] = None + company["lookup_found"] = False + if company.get("cvr_number"): + existing = execute_query_single( + """SELECT id, name FROM customers + WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s + ORDER BY id LIMIT 1""", + (company["cvr_number"],), + ) + if existing: + company["existing_id"] = existing.get("id") + company["existing_name"] = existing.get("name") + official = await get_cvr_service().lookup_by_cvr(company["cvr_number"]) + if official: + company.update({ + "name": official.get("name") or company.get("name"), + "address": official.get("address"), + "city": official.get("city"), + "postal_code": official.get("postal_code") or official.get("zipcode"), + "phone": official.get("phone"), + "email": official.get("email"), + "website": official.get("website"), + "status": official.get("status"), + "source": official.get("source"), + "lookup_found": True, + }) + return { + "filename": file.filename, + "mail": {"subject": parsed.get("subject"), "sender_email": parsed.get("sender_email")}, + "contact": contact, + "company": company, + } + + +@router.post("/contacts/resolve-email-company") +async def resolve_email_company(payload: EmailCompanyResolve): + """Reuse a CVR match or create the approved company exactly once.""" + existing = execute_query_single( + """SELECT id, name, cvr_number FROM customers + WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s + ORDER BY id LIMIT 1""", + (payload.cvr_number,), + ) + if existing: + return {**existing, "created": False} + created = await create_customer(CustomerCreate( + name=payload.name, + cvr_number=payload.cvr_number, + email=payload.email, + phone=payload.phone, + address=payload.address, + city=payload.city, + postal_code=payload.postal_code, + website=payload.website, + is_active=True, + )) + return {**created, "created": True} + + +@router.post("/contacts/{contact_id}/analyze-email") +async def analyze_contact_email(contact_id: int, file: UploadFile = File(...)): + """Parse an Outlook email and return reviewable contact update suggestions.""" + contact = execute_query_single( + "SELECT id, first_name, last_name, email, phone, mobile, title, department FROM contacts WHERE id = %s", + (contact_id,), + ) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + filename = str(file.filename or "").lower() + if not filename.endswith((".eml", ".msg")): + raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes") + content = await file.read() + if not content or len(content) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB") + + from app.services.email_service import EmailService + parser = EmailService() + parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content) + if not parsed: + raise HTTPException(status_code=422, detail="Mailen kunne ikke læses") + suggestions = _contact_suggestions_from_email(contact, parsed) + return { + "filename": file.filename, + "mail": {"subject": parsed.get("subject"), "sender_name": parsed.get("sender_name"), "sender_email": parsed.get("sender_email")}, + "suggestions": suggestions, + } + + +@router.get("/contacts/{contact_id}/emails") +async def get_contact_emails( + contact_id: int, + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), +): + contact = execute_query_single("SELECT id, email FROM contacts WHERE id = %s", (contact_id,)) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + address = str(contact.get("email") or "").strip().lower() + if not address: + return {"items": [], "total": 0, "limit": limit, "offset": offset} + + predicate = "em.deleted_at IS NULL AND LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc)) ~ %s" + params = (_exact_email_pattern(address),) + total = execute_query_single(f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", params) + rows = execute_query( + f""" + SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email, + em.received_date, em.is_read, em.has_attachments, em.attachment_count, + em.linked_case_id, s.titel AS linked_case_title + FROM email_messages em + LEFT JOIN sag_sager s ON s.id = em.linked_case_id + WHERE {predicate} + ORDER BY em.received_date DESC NULLS LAST, em.id DESC + LIMIT %s OFFSET %s + """, + (*params, limit, offset), + ) or [] + return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset} + + +@router.get("/contacts/{contact_id}/cases") +async def get_contact_cases( + contact_id: int, + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), +): + if not execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,)): + raise HTTPException(status_code=404, detail="Contact not found") + predicate = "sk.contact_id = %s AND sk.deleted_at IS NULL AND s.deleted_at IS NULL" + total = execute_query_single( + f"SELECT COUNT(*) AS count FROM sag_kontakter sk JOIN sag_sager s ON s.id = sk.sag_id WHERE {predicate}", + (contact_id,), + ) + rows = execute_query( + f""" + SELECT s.id, s.titel, s.status, s.updated_at, s.customer_id, + cu.name AS customer_name, + s.ansvarlig_bruger_id, + COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name + FROM sag_kontakter sk + JOIN sag_sager s ON s.id = sk.sag_id + LEFT JOIN customers cu ON cu.id = s.customer_id + LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id + WHERE {predicate} + ORDER BY s.updated_at DESC NULLS LAST, s.id DESC + LIMIT %s OFFSET %s + """, + (contact_id, limit, offset), + ) or [] + return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset} + + class ContactCompanyLink(BaseModel): customer_id: int is_primary: bool = True @@ -147,28 +483,32 @@ async def get_contacts( where_clauses = [] params = [] - if search: - where_clauses.append( + normalized_search = " ".join(str(search or "").split()) + if normalized_search: + for token in normalized_search.split(" "): + token_clause = """ + ( + CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s + OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s + OR COALESCE(c.email, '') ILIKE %s + OR COALESCE(c.title, '') ILIKE %s + OR COALESCE(c.department, '') ILIKE %s + OR COALESCE(c.user_company, '') ILIKE %s + OR EXISTS ( + SELECT 1 FROM contact_companies cc2 + JOIN customers cu2 ON cu2.id = cc2.customer_id + WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s + ) """ - ( - c.first_name ILIKE %s - OR c.last_name ILIKE %s - OR c.email ILIKE %s - OR c.phone ILIKE %s - OR c.mobile ILIKE %s - OR c.user_company ILIKE %s - OR EXISTS ( - SELECT 1 - FROM contact_companies cc2 - JOIN customers cu2 ON cu2.id = cc2.customer_id - WHERE cc2.contact_id = c.id - AND cu2.name ILIKE %s - ) - ) - """ - ) - like = f"%{search}%" - params.extend([like, like, like, like, like, like, like]) + token_params: list[Any] = [f"%{token}%"] * 7 + digits = "".join(ch for ch in token if ch.isdigit()) + if len(digits) >= 2: + token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s" + token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s" + token_params.extend([f"%{digits}%", f"%{digits}%"]) + token_clause += ")" + where_clauses.append(token_clause) + params.extend(token_params) if is_active is not None: where_clauses.append("c.is_active = %s") @@ -188,16 +528,33 @@ async def get_contacts( total = count_result[0]['count'] if count_result else 0 # Step 1: Fetch contacts only (stable pagination) + rank_order_sql = "" + rank_params: list[Any] = [] + if normalized_search: + rank_sql = """ + CASE + WHEN LOWER(CONCAT_WS(' ', c.first_name, c.last_name)) = LOWER(%s) THEN 0 + WHEN LOWER(CONCAT_WS(' ', c.last_name, c.first_name)) = LOWER(%s) THEN 1 + WHEN CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s THEN 2 + WHEN CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s THEN 3 + WHEN COALESCE(c.email, '') ILIKE %s THEN 4 + ELSE 5 + END + """ + rank_order_sql = f"{rank_sql}," + rank_params = [normalized_search, normalized_search, f"{normalized_search}%", f"{normalized_search}%", f"{normalized_search}%"] + contacts_query = f""" SELECT c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at FROM contacts c {where_sql} - ORDER BY c.last_name, c.first_name, c.id + ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id LIMIT %s OFFSET %s """ contacts_params = list(params) + contacts_params.extend(rank_params) contacts_params.extend([limit, offset]) contacts = execute_query(contacts_query, tuple(contacts_params)) or [] diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index 080f860..dafbce9 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -116,6 +116,21 @@ top: 1rem; right: 1rem; } + + .contact-email-dropzone { + border: 1px dashed rgba(15, 76, 117, 0.45); + border-radius: 12px; + background: rgba(15, 76, 117, 0.04); + padding: 0.8rem 1rem; + cursor: pointer; + transition: background-color .2s, border-color .2s, transform .2s; + } + + .contact-email-dropzone.is-dragging { + background: rgba(15, 76, 117, 0.12); + border-color: var(--accent); + transform: translateY(-1px); + } {% endblock %} @@ -150,6 +165,18 @@ +
+
+ +
+
Opdatér kontakt fra Outlook-mail
+
Træk en .msg- eller .eml-fil hertil, eller klik for at vælge. Intet ændres uden din godkendelse.
+
+
+
+ +
+
@@ -173,6 +200,16 @@ Kontakter + + +