Compare commits

..

6 Commits

Author SHA1 Message Date
Christian
770f822fc6 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching optional files.
- Integrated automatic screenshot capture functionality when the bug report modal is opened.
- Created a new API endpoint for submitting bug reports, including validation and rate limiting.
- Added database migration for tracking bug report submissions.
- Updated frontend scripts to handle bug report submissions and display status messages.
- Enhanced contact search functionality with improved error handling and backward compatibility.
- Introduced a new button in the UI for accessing the bug report modal.
2026-05-06 07:01:43 +02:00
Christian
71f6372496 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching files.
- Implemented backend API for creating bug reports, including rate limiting and metadata logging.
- Introduced a new database table to track bug report submissions for auditing purposes.
- Enhanced the frontend to capture screenshots automatically and allow manual file uploads.
- Added error handling and user feedback for the bug reporting process.
- Updated existing templates and scripts to integrate the new bug reporting functionality.
2026-05-05 19:13:54 +02:00
Christian
1a44baba62 hotfix: fix economy time-queue order link path to /ordre 2026-05-05 07:42:46 +02:00
Christian
03a1b79737 hotfix: robust local order creation with customer mapping fallback 2026-05-05 07:40:58 +02:00
Christian
e878336537 hotfix: replace legacy missing economic customer number error message 2026-05-05 07:37:16 +02:00
Christian
a5866132ab hotfix: skip economic export when customer number missing (local-only) 2026-05-05 07:33:01 +02:00
42 changed files with 3139 additions and 437 deletions

View File

@ -16,6 +16,11 @@ API_HOST=0.0.0.0
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
# Customer default economics (used as fallback defaults in customer detail)
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=

View File

@ -44,6 +44,11 @@ API_HOST=0.0.0.0
API_PORT=8000
API_RELOAD=false
# Customer default economics (used as fallback defaults in customer detail)
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=

View File

@ -1 +1 @@
2.2.95
2.2.99

View File

View File

View File

@ -0,0 +1,20 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class BugReportPayload(BaseModel):
actual: str = Field(..., min_length=3, max_length=8000)
expected: str = Field(..., min_length=3, max_length=8000)
screenshot_base64: Optional[str] = Field(default=None, max_length=25_000_000)
metadata: Dict[str, Any] = Field(default_factory=dict)
logs: List[Dict[str, Any]] = Field(default_factory=list)
extra_file_name: Optional[str] = Field(default=None, max_length=255)
extra_file_base64: Optional[str] = Field(default=None, max_length=25_000_000)
class BugReportResult(BaseModel):
success: bool
sag_id: int
case_url: str
message: str

View File

@ -0,0 +1,212 @@
import base64
import json
import logging
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Optional
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from app.bug_reports.backend.models import BugReportPayload, BugReportResult
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
router = APIRouter()
UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve()
SAG_FILE_SUBDIR = "sag_files"
(UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True)
def _table_exists(table_name: str) -> bool:
row = execute_query_single(
"""
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = %s
LIMIT 1
""",
(table_name,),
)
return bool(row)
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
# Expected format: data:image/png;base64,....
match = re.match(r"^data:([\w/+.-]+);base64,(.+)$", data_url or "", flags=re.DOTALL)
if not match:
raise HTTPException(status_code=400, detail="Invalid base64 data URL")
content_type = match.group(1)
b64_data = match.group(2)
try:
raw = base64.b64decode(b64_data, validate=True)
except Exception:
raise HTTPException(status_code=400, detail="Invalid base64 encoding")
return raw, content_type
def _store_raw_file(raw: bytes, filename: str) -> tuple[str, int]:
safe_name = Path(filename).name
stored_name = f"{SAG_FILE_SUBDIR}/{uuid4().hex}_{safe_name}"
destination = UPLOAD_BASE_PATH / stored_name
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb") as f:
f.write(raw)
return stored_name, len(raw)
def _create_sag_file_record(sag_id: int, filename: str, content_type: str, size_bytes: int, stored_name: str) -> None:
execute_query(
"""
INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name)
VALUES (%s, %s, %s, %s, %s)
""",
(sag_id, filename, content_type, size_bytes, stored_name),
)
def _rate_limit(user_id: int) -> None:
if not _table_exists("bug_report_submissions"):
# If migration is not yet applied, fail-open to avoid blocking support workflows.
return
max_per_hour = max(1, int(settings.BUG_REPORT_MAX_PER_HOUR))
row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM bug_report_submissions
WHERE user_id = %s
AND created_at >= %s
""",
(user_id, datetime.utcnow() - timedelta(hours=1)),
)
count = int((row or {}).get("count") or 0)
if count >= max_per_hour:
raise HTTPException(status_code=429, detail="Rate limit exceeded for bug reports")
def _resolve_customer_id() -> int:
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
if configured_row:
return int(configured_row["id"])
named_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE LOWER(name) = LOWER(%s)
ORDER BY id ASC
LIMIT 1
""",
("BMC Networks",),
)
if named_row:
return int(named_row["id"])
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
if fallback:
return int(fallback["id"])
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
@router.post("/bug-reports", response_model=BugReportResult)
async def create_bug_report(payload: BugReportPayload, request: Request):
user_id = getattr(request.state, "user_id", None) or 1
_rate_limit(int(user_id))
title_seed = (payload.actual or "").strip().splitlines()[0][:80]
title = f"Bug: {title_seed or 'Ukendt fejl'}"
metadata_json = json.dumps(payload.metadata or {}, ensure_ascii=False, indent=2)
logs_preview = (payload.logs or [])[:50]
logs_json = json.dumps(logs_preview, ensure_ascii=False, indent=2)
description = (
"## Hvad gik galt\n"
f"{payload.actual.strip()}\n\n"
"## Hvad burde være sket\n"
f"{payload.expected.strip()}\n\n"
"## Metadata\n"
f"```json\n{metadata_json}\n```\n\n"
"## Log preview (seneste 50)\n"
f"```json\n{logs_json}\n```\n"
)
customer_id = _resolve_customer_id()
assigned_user_id: Optional[int] = settings.BUG_REPORT_AUTO_ASSIGN_USER_ID
created = execute_query(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id)
VALUES
(%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
title,
description,
"bug_report",
"åben",
customer_id,
assigned_user_id,
user_id,
),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to create bug case")
sag_id = int(created[0]["id"])
# Attach screenshot if provided
if payload.screenshot_base64:
raw, content_type = _decode_data_url(payload.screenshot_base64)
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large")
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
# Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
stored_name, size = _store_raw_file(logs_raw, f"bugreport_{sag_id}_logs.json")
_create_sag_file_record(sag_id, "logs.json", "application/json", size, stored_name)
# Optional extra file
if payload.extra_file_base64 and payload.extra_file_name:
raw, content_type = _decode_data_url(payload.extra_file_base64)
if len(raw) > settings.BUG_REPORT_MAX_ATTACHMENT_BYTES:
raise HTTPException(status_code=400, detail="Extra file too large")
stored_name, size = _store_raw_file(raw, payload.extra_file_name)
_create_sag_file_record(sag_id, payload.extra_file_name, content_type, size, stored_name)
# Track submission for rate-limiting/audit
if _table_exists("bug_report_submissions"):
execute_query(
"""
INSERT INTO bug_report_submissions (sag_id, user_id, screenshot_attached)
VALUES (%s, %s, %s)
""",
(sag_id, user_id, bool(payload.screenshot_base64)),
)
logger.info("✅ Bug report case created: SAG-%s by user_id=%s", sag_id, user_id)
return BugReportResult(
success=True,
sag_id=sag_id,
case_url=f"/sag/{sag_id}/v3",
message="Fejl rapporteret og sag oprettet",
)

View File

@ -160,8 +160,8 @@
.contacts-table-wrap {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
overflow-x: auto;
overflow-y: visible;
max-height: min(68vh, 780px);
overflow: auto;
}
.contacts-table {
@ -813,6 +813,7 @@ let searchQuery = '';
let totalContacts = 0;
let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let availableCompanies = [];
let selectedCompanyIds = new Set();
let currentContactsData = [];
@ -940,6 +941,12 @@ async function loadContacts() {
params.append('is_active', 'false');
}
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
if (queryKey === lastLoadedQueryKey) {
return;
}
lastLoadedQueryKey = queryKey;
const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal });
const data = await response.json();
@ -982,11 +989,9 @@ function displayContacts(contacts) {
const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || [];
const fallbackCompany = (contact.user_company || '').trim();
const companyDisplay = companyNames.length > 0
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
: (fallbackCompany || '-');
const effectiveCompanyCount = companyCount > 0 ? companyCount : (fallbackCompany ? 1 : 0);
: '-';
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const preferredPhone = contact.mobile || contact.phone || '';
const hasEmail = !!contact.email;
@ -996,7 +1001,7 @@ function displayContacts(contacts) {
const safeEmail = escapeHtml(contact.email || '-');
const safeTitle = escapeHtml(contact.title || '-');
const safePhone = escapeHtml(preferredPhone || '-');
const companiesTitle = escapeHtml(companyNames.length ? companyNames.join(', ') : fallbackCompany);
const companiesTitle = escapeHtml(companyNames.join(', '));
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
return `
@ -1035,7 +1040,7 @@ function displayContacts(contacts) {
<td class="text-muted col-title">${safeTitle}</td>
<td class="col-companies">
<span class="company-count-chip" title="${companiesTitle}">
<i class="bi bi-building"></i>${effectiveCompanyCount}
<i class="bi bi-building"></i>${companyCount}
</span>
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
</td>

View File

@ -161,6 +161,11 @@ class Settings(BaseSettings):
TIMETRACKING_ROUND_INCREMENT: float = 0.5
TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
# Customer economic defaults
CUSTOMER_DEFAULT_MARGIN_PERCENT: float = 20.0
CUSTOMER_DEFAULT_INVOICE_FEE: float = 49.0
CUSTOMER_DEFAULT_HOURLY_RATE: float = 1200.0
# Time Tracking Module Safety Flags
TIMETRACKING_VTIGER_READ_ONLY: bool = True
TIMETRACKING_VTIGER_DRY_RUN: bool = True
@ -209,6 +214,15 @@ class Settings(BaseSettings):
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
UPLOAD_DIR: str = "uploads" # Upload directory path
# Bug report capture
BUG_REPORT_ENABLED: bool = True
BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B"
BUG_REPORT_MAX_PER_HOUR: int = 12
BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1
BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1
BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024
BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024
# Offsite Backup Settings (SFTP)
OFFSITE_ENABLED: bool = False
OFFSITE_WEEKLY_DAY: str = "sunday"

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from app.core.config import settings
router = APIRouter()
templates = Jinja2Templates(directory="app")
@ -20,7 +21,10 @@ async def customer_detail_page(request: Request, customer_id: int):
"""
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
"request": request,
"customer_id": customer_id
"customer_id": customer_id,
"customer_default_margin_percent": settings.CUSTOMER_DEFAULT_MARGIN_PERCENT,
"customer_default_invoice_fee": settings.CUSTOMER_DEFAULT_INVOICE_FEE,
"customer_default_hourly_rate": settings.CUSTOMER_DEFAULT_HOURLY_RATE,
})

View File

@ -443,6 +443,26 @@
<span class="info-label">EAN-nummer</span>
<span class="info-value" id="ean">-</span>
</div>
<div class="info-row">
<span class="info-label">Standard avance</span>
<span class="info-value" id="standardMarginPercent">-</span>
</div>
<div class="info-row">
<span class="info-label">Standard timepris</span>
<span class="info-value" id="standardHourlyRate">-</span>
</div>
<div class="info-row">
<span class="info-label">Særlig fragtpris</span>
<span class="info-value" id="specialFreightPrice">-</span>
</div>
<div class="info-row">
<span class="info-label">Leverandørservice</span>
<span class="info-value" id="supplierServiceEnrolled">-</span>
</div>
<div class="info-row">
<span class="info-label">Faktureringsgebyr</span>
<span class="info-value" id="invoiceFeeAmount">-</span>
</div>
<div class="info-row">
<span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span>
@ -1023,6 +1043,43 @@
<input type="text" class="form-control" id="editCity">
</div>
<!-- Economic defaults -->
<div class="col-12 mt-4">
<h6 class="text-muted text-uppercase small fw-bold mb-3">
<i class="bi bi-currency-exchange me-2"></i>Økonomiske standarder
</h6>
</div>
<div class="col-md-6">
<label for="editStandardMarginPercent" class="form-label">Standard avance (%)</label>
<input type="number" class="form-control" id="editStandardMarginPercent" min="0" max="1000" step="0.01">
</div>
<div class="col-md-6">
<label for="editStandardHourlyRate" class="form-label">Standard timepris (DKK)</label>
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
</div>
<div class="col-md-6">
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
</div>
<div class="col-md-6">
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
<label class="form-check-label" for="editSupplierServiceEnrolled">
Tilmeldt leverandørservice
</label>
</div>
</div>
<!-- Status -->
<div class="col-12 mt-4">
<div class="form-check form-switch">
@ -1319,6 +1376,9 @@
<script>
const customerId = parseInt(window.location.pathname.split('/').pop());
const customerDefaultMarginPercent = Number({{ customer_default_margin_percent | tojson }} || 20);
const customerDefaultInvoiceFee = Number({{ customer_default_invoice_fee | tojson }} || 49);
const customerDefaultHourlyRate = Number({{ customer_default_hourly_rate | tojson }} || 1200);
let customerData = null;
let pipelineStages = [];
let allTagsCache = [];
@ -1674,6 +1734,22 @@ function displayCustomer(customer) {
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
document.getElementById('ean').textContent = customer.ean || '-';
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
const freight = customer.special_freight_price;
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
? '-'
: `${Number(freight).toFixed(2)} DKK`;
document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
? '<span class="badge bg-success">Tilmeldt</span>'
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
? '0,00 DKK (deaktiveret)'
: `${Number(invoiceFee).toFixed(2)} DKK`;
document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>';
@ -3899,6 +3975,11 @@ function editCustomer() {
document.getElementById('editAddress').value = customerData.address || '';
document.getElementById('editPostalCode').value = customerData.postal_code || '';
document.getElementById('editCity').value = customerData.city || '';
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
document.getElementById('editIsActive').checked = customerData.is_active !== false;
// Show modal
@ -3907,6 +3988,11 @@ function editCustomer() {
}
async function saveCustomerEdit() {
const marginValue = document.getElementById('editStandardMarginPercent').value;
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
const freightValue = document.getElementById('editSpecialFreightPrice').value;
const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
const updateData = {
name: document.getElementById('editName').value,
cvr_number: document.getElementById('editCvrNumber').value || null,
@ -3920,6 +4006,11 @@ async function saveCustomerEdit() {
address: document.getElementById('editAddress').value || null,
postal_code: document.getElementById('editPostalCode').value || null,
city: document.getElementById('editCity').value || null,
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue),
special_freight_price: freightValue === '' ? null : Number(freightValue),
supplier_service_enrolled: document.getElementById('editSupplierServiceEnrolled').checked,
invoice_fee_amount: invoiceFeeValue === '' ? customerDefaultInvoiceFee : Number(invoiceFeeValue),
is_active: document.getElementById('editIsActive').checked
};

View File

@ -1,6 +1,8 @@
import logging
from collections import defaultdict
from datetime import date
from decimal import Decimal
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
@ -430,6 +432,259 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
return int(order_id)
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
hub_customer_id = customer.get("hub_customer_id")
hub_customer = None
if hub_customer_id:
hub_customer = execute_query_single(
"""
SELECT
standard_hourly_rate,
standard_margin_percent,
special_freight_price,
supplier_service_enrolled,
invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
invoice_fee_amount = Decimal(
str(
(hub_customer or {}).get("invoice_fee_amount")
if (hub_customer or {}).get("invoice_fee_amount") is not None
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
)
)
special_freight_price = (hub_customer or {}).get("special_freight_price")
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
standard_margin_percent = Decimal(
str(
(hub_customer or {}).get("standard_margin_percent")
if (hub_customer or {}).get("standard_margin_percent") is not None
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
)
)
base_hourly_rate = Decimal(
str(
(hub_customer or {}).get("standard_hourly_rate")
if (hub_customer or {}).get("standard_hourly_rate") is not None
else hourly_rate
)
)
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
amount = (qty * unit_price).quantize(Decimal("0.01"))
line_payloads.append(
{
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
"source_type": "timequeue",
"source_id": ids[0] if ids else None,
"description": group["case_title"],
"quantity": float(qty),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "timer",
"product_id": None,
"selected": True,
"amount": float(amount),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": group["sag_id"],
"time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None,
"meta": {
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
"standard_margin_percent": float(effective_margin_percent),
},
}
)
if special_freight_amount > 0:
line_payloads.append(
{
"line_key": f"freight:{hub_customer_id or customer_id}",
"source_type": "freight",
"source_id": None,
"description": "Særlig fragtpris",
"quantity": 1.0,
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
}
)
# Fee line is included by default unless customer-specific value is 0.
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
"quantity": 1.0,
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
"meta": {
"standard_margin_percent": float(standard_margin_percent),
"supplier_service_enrolled": supplier_service_enrolled,
},
}
)
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
draft_title,
int(hub_customer_id) if hub_customer_id else None,
json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue",
1,
user_id,
json.dumps({}, ensure_ascii=False),
invoice_aggregate_key,
),
)
if not draft:
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
return int(draft["id"])
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
Accepts:
- direct tmodule customer id
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
"""
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
return int(row["id"]) if row else None
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
row = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
return int(row["id"]) if row else None
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct = _find_by_tmodule_id(cid)
if direct:
return direct
mapped = _find_by_hub_customer_id(cid)
if mapped:
return mapped
if sag_id is not None:
try:
sid = int(sag_id)
except (TypeError, ValueError):
sid = None
if sid and sid > 0:
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id:
mapped = _find_by_hub_customer_id(int(hub_customer_id))
if mapped:
return mapped
return None
@router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
ids = _ensure_ids(payload.ids)
@ -466,15 +721,11 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
raise HTTPException(status_code=400, detail="No eligible entries found")
# Local order creation must not depend on e-conomic data/mapping.
# We only require billable entries; billing_method can be invoice/prepaid/fixed_price/internal.
selected_order_ids = [
int(r["id"])
for r in rows
if bool(r.get("billable", True))
]
# Selected entries are converted to local orders regardless of billing method.
selected_order_ids = [int(r["id"]) for r in rows]
if not selected_order_ids:
raise HTTPException(status_code=400, detail="No selected entries are billable")
raise HTTPException(status_code=400, detail="No selected entries found")
placeholders_invoice = ",".join(["%s"] * len(selected_order_ids))
execute_update(
@ -497,41 +748,58 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
if int(row["id"]) not in selected_order_ids:
continue
cust_id = row.get("customer_id")
if cust_id is None:
resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not resolved_customer_id:
skipped_missing_customer.append(int(row["id"]))
continue
rows_by_customer[int(cust_id)].append(row)
rows_by_customer[int(resolved_customer_id)].append(row)
created_orders = []
created_drafts = []
failed_customers: List[Dict[str, Any]] = []
for cust_id, cust_rows in rows_by_customer.items():
order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
created_orders.append({"customer_id": cust_id, "order_id": order_id})
try:
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
except HTTPException as ex:
failed_customers.append(
{
"customer_id": cust_id,
"entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
"error": str(ex.detail),
}
)
if not created_orders:
if not created_drafts:
if skipped_missing_customer:
raise HTTPException(
status_code=400,
detail="No local orders created: selected entries are missing customer linkage",
)
if failed_customers:
raise HTTPException(
status_code=400,
detail="No local orders created: customer data is invalid for selected entries",
)
raise HTTPException(status_code=400, detail="No local orders created")
# Time queue must never push directly to e-conomic.
# Orders are created locally and can be transferred manually from Orders page.
order_ids = [o["order_id"] for o in created_orders]
orders_url = "/ordrer"
if len(order_ids) == 1:
orders_url = f"/ordrer/{order_ids[0]}"
draft_ids = [o["draft_id"] for o in created_drafts]
orders_url = "/ordre"
if len(draft_ids) == 1:
orders_url = f"/ordre/{draft_ids[0]}"
return {
"success": True,
"selected": len(ids),
"billable_candidates": len(selected_order_ids),
"created_orders": created_orders,
"order_candidates": len(selected_order_ids),
"created_drafts": created_drafts,
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
"skipped_missing_customer": skipped_missing_customer,
"failed_customers": failed_customers,
"orders_url": orders_url,
"message": "Lokale ordrer oprettet. Overfoer til e-conomic fra Ordre-siden.",
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
}
except HTTPException:
raise

View File

@ -455,14 +455,19 @@
method: 'POST',
body: JSON.stringify({ ids }),
});
const orders = (result.created_orders || []).map((x) => {
return `customer ${x.customer_id}, order ${x.order_id}`;
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
const draftId = x.draft_id || x.order_id;
return `customer ${x.customer_id}, draft ${draftId}`;
}).join('\n');
const skipped = (result.skipped_missing_customer || []);
const orderMessage = orders || 'Ingen ordrer oprettet';
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
alert(`Lokale ordrer oprettet:\n${orderMessage}${skippedMsg}${nextStep}`);
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
: '';
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
await loadCustomers();
await loadEntries();
} catch (err) {

View File

@ -35,6 +35,11 @@ class CustomerUpdate(BaseModel):
mobile_phone: Optional[str] = None
invoice_email: Optional[str] = None
is_active: Optional[bool] = None
standard_margin_percent: Optional[float] = None
standard_hourly_rate: Optional[float] = None
special_freight_price: Optional[float] = None
supplier_service_enrolled: Optional[bool] = None
invoice_fee_amount: Optional[float] = None
class Customer(CustomerBase):

View File

@ -1181,6 +1181,12 @@ async def create_contact(location_id: int, data: ContactCreate):
- 500: Database error
"""
try:
def _none_if_empty(value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
# Check location exists
location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
location_check = execute_query(location_query, (location_id,))
@ -1194,6 +1200,90 @@ async def create_contact(location_id: int, data: ContactCreate):
location_name = location_check[0]['name']
if not data.existing_contact_id:
raise HTTPException(
status_code=400,
detail="Du skal vælge en eksisterende kontakt"
)
existing_contact_query = """
SELECT id, first_name, last_name, email, phone, mobile, title
FROM contacts
WHERE id = %s
"""
existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,))
if not existing_contact_result:
raise HTTPException(
status_code=404,
detail=f"Existing contact with id {data.existing_contact_id} not found"
)
existing_contact = existing_contact_result[0]
contact_name = (
f"{(existing_contact.get('first_name') or '').strip()} "
f"{(existing_contact.get('last_name') or '').strip()}"
).strip()
contact_email = _none_if_empty(existing_contact.get('email'))
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
contact_role = _none_if_empty(data.role) or _none_if_empty(existing_contact.get('title'))
if not contact_name:
raise HTTPException(status_code=400, detail="Valgt kontakt mangler navn")
existing_relation_result = execute_query(
"""
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
role, is_primary, created_at
FROM locations_contacts
WHERE location_id = %s
AND related_contact_id = %s
AND deleted_at IS NULL
LIMIT 1
""",
(location_id, data.existing_contact_id),
)
if existing_relation_result:
existing_relation = existing_relation_result[0]
if data.is_primary and not existing_relation.get("is_primary"):
execute_query(
"""
UPDATE locations_contacts
SET is_primary = false
WHERE location_id = %s AND deleted_at IS NULL
""",
(location_id,),
)
execute_query(
"""
UPDATE locations_contacts
SET is_primary = true
WHERE id = %s
RETURNING id, location_id, related_contact_id, contact_name, contact_email,
contact_phone, role, is_primary, created_at
""",
(existing_relation["id"],),
)
existing_relation_result = execute_query(
"""
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
role, is_primary, created_at
FROM locations_contacts
WHERE id = %s
LIMIT 1
""",
(existing_relation["id"],),
)
logger.info(
" Existing contact relation reused for location %s and contact %s",
location_id,
data.existing_contact_id,
)
return Contact(**existing_relation_result[0])
# If is_primary is true, unset primary flag on other contacts
if data.is_primary:
unset_primary_query = """
@ -1203,24 +1293,54 @@ async def create_contact(location_id: int, data: ContactCreate):
"""
execute_query(unset_primary_query, (location_id,))
# INSERT new contact
insert_query = """
INSERT INTO locations_contacts (
location_id, contact_name, contact_email, contact_phone,
role, is_primary, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, NOW(), NOW())
RETURNING *
"""
has_related_contact_id_column = bool(execute_query(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = 'locations_contacts'
AND column_name = 'related_contact_id'
LIMIT 1
"""
))
params = (
location_id,
data.contact_name,
data.contact_email,
data.contact_phone,
data.role,
data.is_primary
)
# INSERT new contact
if has_related_contact_id_column:
insert_query = """
INSERT INTO locations_contacts (
location_id, related_contact_id, contact_name, contact_email, contact_phone,
role, is_primary, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING *
"""
params = (
location_id,
data.existing_contact_id,
contact_name,
contact_email,
contact_phone,
contact_role,
data.is_primary,
)
else:
insert_query = """
INSERT INTO locations_contacts (
location_id, contact_name, contact_email, contact_phone,
role, is_primary, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING *
"""
params = (
location_id,
contact_name,
contact_email,
contact_phone,
contact_role,
data.is_primary,
)
result = execute_query(insert_query, params)
@ -1230,7 +1350,16 @@ async def create_contact(location_id: int, data: ContactCreate):
contact = Contact(**result[0])
logger.info(f"✅ Contact added: {data.contact_name} at {location_name} (Location ID: {location_id})")
if data.existing_contact_id:
logger.info(
"✅ Contact added from existing contact %s: %s at %s (Location ID: %s)",
data.existing_contact_id,
contact_name,
location_name,
location_id,
)
else:
logger.info(f"✅ Contact added: {contact_name} at {location_name} (Location ID: {location_id})")
return contact
except HTTPException:

View File

@ -412,8 +412,85 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,)
)
contacts = execute_query(
"""
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
role, is_primary, created_at
FROM locations_contacts
WHERE location_id = %s AND deleted_at IS NULL
ORDER BY is_primary DESC, contact_name ASC
""",
(id,)
)
operating_hours = execute_query(
"""
SELECT id, location_id, day_of_week,
CASE day_of_week
WHEN 0 THEN 'Mandag'
WHEN 1 THEN 'Tirsdag'
WHEN 2 THEN 'Onsdag'
WHEN 3 THEN 'Torsdag'
WHEN 4 THEN 'Fredag'
WHEN 5 THEN 'Lørdag'
WHEN 6 THEN 'Søndag'
END AS day_name,
open_time, close_time, is_open, notes
FROM locations_hours
WHERE location_id = %s
ORDER BY day_of_week ASC
""",
(id,)
)
services = execute_query(
"""
SELECT id, location_id, service_name, is_available, created_at
FROM locations_services
WHERE location_id = %s AND deleted_at IS NULL
ORDER BY service_name ASC
""",
(id,)
)
capacity = execute_query(
"""
SELECT id, location_id, capacity_type, total_capacity, used_capacity, last_updated
FROM locations_capacity
WHERE location_id = %s
ORDER BY capacity_type ASC
""",
(id,)
)
hardware = execute_query(
"""
SELECT id, asset_type, brand, model, serial_number, status
FROM hardware_assets
WHERE current_location_id = %s AND deleted_at IS NULL
ORDER BY brand ASC, model ASC, serial_number ASC
""",
(id,)
)
audit_log = execute_query(
"""
SELECT id, location_id, event_type, user_id, changes, created_at
FROM locations_audit_log
WHERE location_id = %s
ORDER BY created_at DESC
""",
(id,)
)
location["hierarchy"] = hierarchy
location["children"] = children
location["contacts"] = contacts or []
location["operating_hours"] = operating_hours or []
location["services"] = services or []
location["capacity"] = capacity or []
location["hardware"] = hardware or []
location["audit_log"] = audit_log or []
# Query customers
customers = execute_query("""

View File

@ -117,8 +117,18 @@ class ContactBase(BaseModel):
)
class ContactCreate(ContactBase):
"""Request model for creating contact"""
class ContactCreate(BaseModel):
"""Request model for linking an existing global contact to a location"""
existing_contact_id: int = Field(
...,
ge=1,
description="ID of existing global contact"
)
role: Optional[str] = Field(
None,
max_length=100,
description="Optional location-specific role override"
)
is_primary: bool = Field(False, description="Set as primary contact for location")
@ -135,6 +145,7 @@ class Contact(ContactBase):
"""Full contact response model"""
id: int
location_id: int
related_contact_id: Optional[int] = None
is_primary: bool
created_at: datetime

View File

@ -2,8 +2,210 @@
{% block title %}{{ location.name }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.locations-detail-page {
--loc-accent: var(--accent, #0f4c75);
}
.locations-detail-page .case-hero {
background: var(--bg-card, #fff);
border-radius: 16px;
overflow: visible;
box-shadow:
0 0 0 1px rgba(0,0,0,0.06),
0 4px 6px -1px rgba(0,0,0,0.05),
0 16px 32px -8px rgba(15,76,117,0.10);
}
.locations-detail-page .case-hero-identity {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, rgba(15,76,117,0.05) 0%, rgba(15,76,117,0.01) 100%);
border-bottom: 1px solid rgba(0,0,0,0.06);
border-radius: 16px 16px 0 0;
flex-wrap: wrap;
gap: 0.5rem;
}
.locations-detail-page .case-id-chip {
display: inline-flex;
align-items: center;
font-size: 1rem;
font-weight: 900;
letter-spacing: -0.4px;
color: var(--loc-accent);
background: color-mix(in srgb, var(--loc-accent) 10%, transparent);
border: 1.5px solid color-mix(in srgb, var(--loc-accent) 30%, transparent);
border-radius: 8px;
padding: 0.2em 0.65em;
}
.locations-detail-page .case-type-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--loc-accent);
background: color-mix(in srgb, var(--loc-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--loc-accent) 25%, transparent);
border-radius: 999px;
padding: 0.32em 0.8em;
}
.locations-detail-page .case-status-chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.04em;
border-radius: 999px;
padding: 0.3em 0.85em;
border: 1px solid transparent;
}
.locations-detail-page .case-status-chip.open {
background: #dcfce7;
color: #15803d;
border-color: #86efac;
}
.locations-detail-page .case-status-chip.closed {
background: #f1f5f9;
color: #475569;
border-color: #cbd5e1;
}
.locations-detail-page .case-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.locations-detail-page .case-hero-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
padding: 0.75rem 1rem 0.95rem;
}
.locations-detail-page .case-meta-cell {
border: 1px solid rgba(0,0,0,0.06);
border-radius: 10px;
background: color-mix(in srgb, var(--loc-accent) 4%, var(--bg-card, #fff));
padding: 0.58rem 0.7rem;
}
.locations-detail-page .hero-meta-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 700;
color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 3px;
}
.locations-detail-page .hero-meta-value {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
}
.locations-detail-page #locationTabs {
border-bottom: none;
gap: 0.45rem;
}
.locations-detail-page #locationTabs .nav-link {
border: 1px solid rgba(0,0,0,0.1);
border-radius: 999px;
color: var(--text-secondary);
background: var(--bg-card, #fff);
font-weight: 600;
padding: 0.44rem 0.82rem;
transition: all 0.16s ease;
}
.locations-detail-page #locationTabs .nav-link:hover,
.locations-detail-page #locationTabs .nav-link:focus-visible {
border-color: color-mix(in srgb, var(--loc-accent) 45%, transparent);
color: var(--loc-accent);
}
.locations-detail-page #locationTabs .nav-link.active {
background: color-mix(in srgb, var(--loc-accent) 12%, var(--bg-card, #fff));
border-color: color-mix(in srgb, var(--loc-accent) 40%, transparent);
color: var(--loc-accent);
}
.locations-detail-page .location-tab-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2rem;
height: 1.2rem;
border-radius: 999px;
padding: 0 0.34rem;
font-size: 0.66rem;
font-weight: 800;
background: color-mix(in srgb, var(--loc-accent) 14%, transparent);
color: var(--loc-accent);
}
.locations-detail-page .card {
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.08) !important;
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08);
}
.locations-detail-page .list-group-item {
border-radius: 0.8rem !important;
margin-bottom: 0.45rem;
border: 1px solid rgba(15, 76, 117, 0.1);
}
.locations-detail-page .timeline-item {
position: relative;
}
.locations-detail-page .timeline-item::before {
content: "";
position: absolute;
left: 6px;
top: 20px;
bottom: -14px;
width: 1px;
background: rgba(15, 76, 117, 0.2);
}
.locations-detail-page .timeline-item:last-child::before {
display: none;
}
@media (max-width: 767.98px) {
.locations-detail-page {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.locations-detail-page .case-hero-meta {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="container-fluid px-4 py-4 locations-detail-page">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
@ -16,24 +218,10 @@
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h1 class="h2 fw-700 mb-2">{{ location.name }}</h1>
{% if location.hierarchy %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
{% for node in location.hierarchy %}
<li class="breadcrumb-item">
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
{{ node.name }}
</a>
</li>
{% endfor %}
<li class="breadcrumb-item active" aria-current="page">{{ location.name }}</li>
</ol>
</nav>
{% endif %}
<div class="d-flex gap-2 align-items-center">
<div class="case-hero">
<div class="case-hero-identity">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="case-id-chip">Lokation #{{ location.id }}</span>
{% set type_label = {
'kompleks': 'Kompleks',
'bygning': 'Bygning',
@ -56,41 +244,62 @@
'vehicle': '#8e44ad'
}.get(location.location_type, '#6c757d') %}
<span class="badge" style="background-color: {{ type_color }}; color: white;">
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
{{ type_label }}
</span>
{% if location.parent_location_id and location.parent_location_name %}
<span class="text-muted small">
<i class="bi bi-diagram-3 me-1"></i>
<a href="/app/locations/{{ location.parent_location_id }}" class="text-decoration-none">
{{ location.parent_location_name }}
</a>
{% if location.is_active %}
<span class="case-status-chip open">
<span class="case-status-dot"></span>Aktiv
</span>
{% else %}
<span class="case-status-chip closed">
<span class="case-status-dot"></span>Inaktiv
</span>
{% endif %}
{% if location.is_active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
<i class="bi bi-pencil me-2"></i>Rediger
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash me-2"></i>Slet
</button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-2"></i>Tilbage
</a>
</div>
</div>
<div class="d-flex gap-2">
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
<i class="bi bi-pencil me-2"></i>Rediger
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash me-2"></i>Slet
</button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-2"></i>Tilbage
</a>
<div class="case-hero-meta">
<div class="case-meta-cell">
<div class="hero-meta-label">Navn</div>
<div class="hero-meta-value">{{ location.name }}</div>
</div>
<div class="case-meta-cell">
<div class="hero-meta-label">Overordnet</div>
{% if location.parent_location_id and location.parent_location_name %}
<a href="/app/locations/{{ location.parent_location_id }}" class="hero-meta-value text-decoration-none">
{{ location.parent_location_name }}
</a>
{% else %}
<span class="hero-meta-value text-muted">Ingen</span>
{% endif %}
</div>
<div class="case-meta-cell">
<div class="hero-meta-label">Kontakter</div>
<div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div>
</div>
<div class="case-meta-cell">
<div class="hero-meta-label">Tjenester</div>
<div class="hero-meta-value">{{ location.services|length if location.services else 0 }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" role="tablist">
<ul class="nav nav-tabs mb-4" id="locationTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="infoTab" data-bs-toggle="tab" data-bs-target="#infoContent" type="button" role="tab" aria-controls="infoContent" aria-selected="true">
<i class="bi bi-info-circle me-2"></i>Oplysninger
@ -99,6 +308,7 @@
<li class="nav-item" role="presentation">
<button class="nav-link" id="contactsTab" data-bs-toggle="tab" data-bs-target="#contactsContent" type="button" role="tab" aria-controls="contactsContent" aria-selected="false">
<i class="bi bi-people me-2"></i>Kontakter
<span class="location-tab-count-badge ms-1">{{ location.contacts|length if location.contacts else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
@ -109,11 +319,13 @@
<li class="nav-item" role="presentation">
<button class="nav-link" id="servicesTab" data-bs-toggle="tab" data-bs-target="#servicesContent" type="button" role="tab" aria-controls="servicesContent" aria-selected="false">
<i class="bi bi-tools me-2"></i>Tjenester
<span class="location-tab-count-badge ms-1">{{ location.services|length if location.services else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="capacityTab" data-bs-toggle="tab" data-bs-target="#capacityContent" type="button" role="tab" aria-controls="capacityContent" aria-selected="false">
<i class="bi bi-graph-up me-2"></i>Kapacitet
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
@ -124,6 +336,7 @@
<li class="nav-item" role="presentation">
<button class="nav-link" id="hardwareTab" data-bs-toggle="tab" data-bs-target="#hardwareContent" type="button" role="tab" aria-controls="hardwareContent" aria-selected="false">
<i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
<span class="location-tab-count-badge ms-1">{{ location.hardware|length if location.hardware else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
@ -301,7 +514,13 @@
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="fw-600 mb-1">{{ contact.contact_name }}</h6>
<h6 class="fw-600 mb-1">
{% if contact.related_contact_id %}
<a href="/contacts/{{ contact.related_contact_id }}" class="text-decoration-none">{{ contact.contact_name }}</a>
{% else %}
{{ contact.contact_name }}
{% endif %}
</h6>
<p class="small text-muted mb-2">
{% if contact.role %}{{ contact.role }}{% endif %}
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
@ -663,16 +882,21 @@
<form id="addContactForm">
<div class="modal-body">
<div class="mb-3">
<label for="contactName" class="form-label">Navn *</label>
<input type="text" class="form-control" id="contactName" required>
<label for="existingContactSearch" class="form-label">Søg eksisterende kontakt</label>
<input type="text" class="form-control" id="existingContactSearch" placeholder="Skriv navn, email eller telefon...">
<div class="form-text">Vælg en eksisterende kontakt for at udfylde felterne automatisk.</div>
<div id="existingContactResults" class="list-group mt-2 d-none" style="max-height: 220px; overflow-y: auto;"></div>
</div>
<div class="mb-3">
<label for="contactEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="contactEmail">
<div id="selectedExistingContact" class="alert alert-info py-2 px-3 d-none" role="alert">
<div class="d-flex justify-content-between align-items-center">
<span id="selectedExistingContactText" class="small mb-0"></span>
<button type="button" class="btn btn-link btn-sm p-0" id="clearExistingContactBtn">Fjern</button>
</div>
</div>
<input type="hidden" id="existingContactId" value="">
<div class="mb-3">
<label for="contactPhone" class="form-label">Telefon</label>
<input type="tel" class="form-control" id="contactPhone">
<label class="form-label">Kontaktoplysninger</label>
<div class="form-control-plaintext small text-muted" id="selectedContactMeta">Vælg en kontakt for at se email og telefon.</div>
</div>
<div class="mb-3">
<label for="contactRole" class="form-label">Rolle</label>
@ -781,6 +1005,129 @@
document.addEventListener('DOMContentLoaded', function() {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}';
const existingContactSearchInput = document.getElementById('existingContactSearch');
const existingContactResultsContainer = document.getElementById('existingContactResults');
const existingContactIdInput = document.getElementById('existingContactId');
const selectedExistingContactAlert = document.getElementById('selectedExistingContact');
const selectedExistingContactText = document.getElementById('selectedExistingContactText');
const selectedContactMeta = document.getElementById('selectedContactMeta');
const clearExistingContactBtn = document.getElementById('clearExistingContactBtn');
const contactRoleInput = document.getElementById('contactRole');
const addContactModalElement = document.getElementById('addContactModal');
const addContactForm = document.getElementById('addContactForm');
const addContactSubmitBtn = addContactForm?.querySelector('button[type="submit"]');
let existingContactResults = [];
let contactSearchDebounceTimer = null;
let isSavingContact = false;
function clearExistingContactSelection() {
existingContactIdInput.value = '';
selectedExistingContactText.textContent = '';
selectedExistingContactAlert.classList.add('d-none');
selectedContactMeta.textContent = 'Vælg en kontakt for at se email og telefon.';
}
function hideExistingContactResults() {
existingContactResults = [];
existingContactResultsContainer.innerHTML = '';
existingContactResultsContainer.classList.add('d-none');
}
function buildFullName(contact) {
const firstName = (contact.first_name || '').trim();
const lastName = (contact.last_name || '').trim();
return `${firstName} ${lastName}`.trim();
}
function selectExistingContact(contact) {
const fullName = buildFullName(contact);
const email = contact.email || '';
const phone = contact.mobile || contact.phone || '';
existingContactIdInput.value = String(contact.id || '');
selectedExistingContactText.textContent = `Valgt: ${fullName || 'Kontakt'} (ID: ${contact.id})`;
selectedExistingContactAlert.classList.remove('d-none');
selectedContactMeta.textContent = `${email || 'Ingen email'} • ${phone || 'Ingen telefon'}`;
hideExistingContactResults();
existingContactSearchInput.value = fullName;
}
function renderExistingContactResults(results) {
if (!results || !results.length) {
hideExistingContactResults();
return;
}
existingContactResults = results;
existingContactResultsContainer.innerHTML = results.map((contact, index) => {
const fullName = buildFullName(contact) || `Kontakt #${contact.id}`;
const secondaryInfo = [contact.email, contact.mobile || contact.phone, contact.user_company]
.filter(Boolean)
.join(' • ');
return `
<button type="button" class="list-group-item list-group-item-action existing-contact-result" data-index="${index}">
<div class="fw-500">${fullName}</div>
<div class="small text-muted">${secondaryInfo || 'Ingen ekstra info'}</div>
</button>
`;
}).join('');
existingContactResultsContainer.classList.remove('d-none');
}
async function searchExistingContacts(term) {
if (!term || term.trim().length < 2) {
hideExistingContactResults();
return;
}
try {
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(term.trim())}`);
if (!response.ok) {
hideExistingContactResults();
return;
}
const contacts = await response.json();
renderExistingContactResults(contacts || []);
} catch (error) {
console.error('Error searching contacts:', error);
hideExistingContactResults();
}
}
existingContactSearchInput.addEventListener('input', function(e) {
const term = e.target.value;
if (contactSearchDebounceTimer) {
clearTimeout(contactSearchDebounceTimer);
}
contactSearchDebounceTimer = setTimeout(() => {
searchExistingContacts(term);
}, 220);
});
existingContactResultsContainer.addEventListener('click', function(e) {
const button = e.target.closest('.existing-contact-result');
if (!button) return;
const index = Number(button.dataset.index);
const selectedContact = existingContactResults[index];
if (selectedContact) {
selectExistingContact(selectedContact);
}
});
clearExistingContactBtn.addEventListener('click', function() {
clearExistingContactSelection();
existingContactSearchInput.value = '';
hideExistingContactResults();
});
addContactModalElement.addEventListener('hidden.bs.modal', function() {
hideExistingContactResults();
clearExistingContactSelection();
existingContactSearchInput.value = '';
});
// Delete location
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
@ -803,14 +1150,26 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Add contact form
document.getElementById('addContactForm').addEventListener('submit', function(e) {
addContactForm.addEventListener('submit', function(e) {
e.preventDefault();
if (isSavingContact) {
return;
}
if (!existingContactIdInput.value) {
alert('Vælg en eksisterende kontakt fra søgningen før du gemmer.');
return;
}
isSavingContact = true;
if (addContactSubmitBtn) {
addContactSubmitBtn.disabled = true;
addContactSubmitBtn.textContent = 'Gemmer...';
}
const contactData = {
location_id: locationId,
contact_name: document.getElementById('contactName').value,
contact_email: document.getElementById('contactEmail').value,
contact_phone: document.getElementById('contactPhone').value,
role: document.getElementById('contactRole').value,
role: contactRoleInput.value,
existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
is_primary: document.getElementById('isPrimaryContact').checked
};
fetch(`/api/v1/locations/${locationId}/contacts`, {
@ -818,13 +1177,35 @@ document.addEventListener('DOMContentLoaded', function() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contactData)
})
.then(response => {
.then(async response => {
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
setTimeout(() => location.reload(), 300);
return;
}
let detail = 'Fejl ved gem af kontaktlink';
try {
const payload = await response.json();
if (payload && payload.detail) {
detail = String(payload.detail);
}
} catch (e) {
// ignore parse errors and keep default message
}
alert(detail);
})
.catch(error => console.error('Error:', error));
.catch(error => {
console.error('Error:', error);
alert('Netværksfejl ved gem af kontaktlink');
})
.finally(() => {
isSavingContact = false;
if (addContactSubmitBtn) {
addContactSubmitBtn.disabled = false;
addContactSubmitBtn.textContent = 'Tilføj';
}
});
});
// Add service form

View File

@ -2,8 +2,91 @@
{% block title %}Lokaliteter - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.locations-list-page {
--loc-accent: #0f4c75;
--loc-accent-soft: rgba(15, 76, 117, 0.08);
--loc-border: rgba(15, 76, 117, 0.16);
}
.locations-list-page .locations-hero {
border: 1px solid var(--loc-border);
background:
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
border-radius: 1rem;
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
}
[data-theme="dark"] .locations-list-page .locations-hero {
background:
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
}
.locations-list-page .stat-tile {
background: var(--loc-accent-soft);
border: 1px solid var(--loc-border);
border-radius: 0.9rem;
padding: 0.8rem 0.95rem;
}
.locations-list-page .stat-tile .stat-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--loc-accent);
line-height: 1.1;
}
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
color: #8fd0ff;
}
.locations-list-page .table thead th {
font-size: 0.8rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 700;
}
.locations-list-page .location-row {
transition: background-color 0.18s ease, transform 0.16s ease;
}
.locations-list-page .location-row:hover {
background-color: rgba(52, 152, 219, 0.08);
}
.locations-list-page .toggle-row {
color: var(--loc-accent);
}
.locations-list-page .shortcut-hint {
border: 1px dashed var(--loc-border);
border-radius: 0.55rem;
padding: 0.3rem 0.45rem;
font-size: 0.72rem;
color: var(--text-secondary);
}
@media (max-width: 767.98px) {
.locations-list-page {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.locations-list-page .stat-tile {
padding: 0.65rem 0.75rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="container-fluid px-4 py-4 locations-list-page">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
@ -15,8 +98,41 @@
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 fw-700 mb-2">Lokaliteter</h1>
<p class="text-muted small">Oversigt over alle lokationer og faciliteter</p>
<div class="locations-hero p-3 p-lg-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
</div>
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
</div>
<div class="row g-2 mt-2">
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Total</div>
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Aktive</div>
<div class="stat-value" id="statActive">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Inaktive</div>
<div class="stat-value" id="statInactive">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Synlige nu</div>
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -294,12 +410,32 @@
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('locationSearch');
const visibleCount = document.getElementById('visibleCount');
const statTotal = document.getElementById('statTotal');
const statActive = document.getElementById('statActive');
const statInactive = document.getElementById('statInactive');
const statVisible = document.getElementById('statVisible');
const rows = Array.from(document.querySelectorAll('.location-row'));
const rowById = new Map();
const parentById = new Map();
const childrenById = new Map();
function updateSummaryStats() {
const total = rows.length;
const active = rows.filter(row => {
const statusText = row.querySelector('td:nth-child(5)')?.innerText?.toLowerCase() || '';
return statusText.includes('aktiv') && !statusText.includes('inaktiv');
}).length;
const inactive = Math.max(total - active, 0);
const visible = rows.filter(row => !row.classList.contains('d-none')).length;
if (statTotal) statTotal.textContent = String(total);
if (statActive) statActive.textContent = String(active);
if (statInactive) statInactive.textContent = String(inactive);
if (statVisible) statVisible.textContent = String(visible);
if (visibleCount) visibleCount.textContent = String(visible);
}
rows.forEach(row => {
const id = row.getAttribute('data-location-id');
const parentId = row.getAttribute('data-parent-id') || null;
@ -374,6 +510,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
collapseAll();
updateSummaryStats();
function toggleNode(targetId) {
const row = rowById.get(targetId);
@ -442,6 +579,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (visibleCount) {
visibleCount.textContent = String(visible);
}
if (statVisible) {
statVisible.textContent = String(visible);
}
}
if (searchInput) {
@ -450,6 +590,14 @@ document.addEventListener('DOMContentLoaded', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(applySearchFilter, 150);
});
document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement !== searchInput) {
e.preventDefault();
searchInput.focus();
searchInput.select();
}
});
}
// Select all checkbox

View File

@ -67,7 +67,10 @@ class OrdreEconomicExportService:
if not customer.get("economic_customer_number"):
raise HTTPException(
status_code=400,
detail="Kunden mangler e-conomic kundenummer i Customers modulet",
detail=(
f"Kan ikke overfoere ordre til e-conomic: Customer '{customer.get('name')}' "
"mangler e-conomic kundenummer. Lokal ordre er bevaret."
),
)
selected_lines = [line for line in lines if bool(line.get("selected", True))]

View File

@ -18,7 +18,7 @@ ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
class OrdreLineInput(BaseModel):
line_key: str
source_type: str
source_id: int
source_id: Optional[int] = None
description: str
quantity: float = Field(gt=0)
unit_price: float = Field(ge=0)
@ -45,6 +45,10 @@ class OrdreDraftUpsertRequest(BaseModel):
layout_number: Optional[int] = None
class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2)
def _safe_json_field(value: Any) -> Any:
if value is None:
return None
@ -572,3 +576,114 @@ async def delete_ordre_draft(draft_id: int, http_request: Request):
except Exception as e:
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to delete ordre draft")
@router.post("/ordre/drafts/consolidate")
async def consolidate_ordre_drafts(payload: OrdreDraftConsolidateRequest, http_request: Request):
"""Consolidate two or more drafts for the same customer into one draft."""
try:
draft_ids = sorted(set(int(x) for x in payload.draft_ids if int(x) > 0))
if len(draft_ids) < 2:
raise HTTPException(status_code=400, detail="Select at least two drafts to consolidate")
placeholders = ",".join(["%s"] * len(draft_ids))
from app.core.database import execute_query, execute_query_single
rows = execute_query(
f"""
SELECT *
FROM ordre_drafts
WHERE id IN ({placeholders})
ORDER BY id ASC
""",
tuple(draft_ids),
) or []
if len(rows) != len(draft_ids):
raise HTTPException(status_code=404, detail="One or more drafts were not found")
customer_ids = {row.get("customer_id") for row in rows}
if len(customer_ids) != 1:
raise HTTPException(status_code=400, detail="Drafts must belong to the same customer")
customer_id = next(iter(customer_ids))
if customer_id is None:
raise HTTPException(status_code=400, detail="Drafts without customer_id cannot be consolidated")
blocked_statuses = {"exported", "posted", "paid"}
for row in rows:
sync_status = str(row.get("sync_status") or "pending").strip().lower()
if sync_status in blocked_statuses:
raise HTTPException(
status_code=400,
detail=f"Draft #{row.get('id')} has status '{sync_status}' and cannot be consolidated",
)
primary = rows[0]
secondary_ids = [int(row["id"]) for row in rows[1:]]
merged_lines: List[Dict[str, Any]] = []
for row in rows:
lines = _safe_json_field(row.get("lines_json")) or []
if isinstance(lines, list):
merged_lines.extend(lines)
notes_parts = [str(row.get("notes")).strip() for row in rows if row.get("notes")]
merged_notes = "\n\n".join(part for part in notes_parts if part)
aggregate_key = primary.get("invoice_aggregate_key") or f"consolidated-customer-{customer_id}"
keep_id = int(primary["id"])
execute_query(
"""
UPDATE ordre_drafts
SET lines_json = %s::jsonb,
notes = %s,
invoice_aggregate_key = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
json.dumps(merged_lines, ensure_ascii=False),
merged_notes or None,
aggregate_key,
keep_id,
),
)
if secondary_ids:
delete_placeholders = ",".join(["%s"] * len(secondary_ids))
execute_query(
f"DELETE FROM ordre_drafts WHERE id IN ({delete_placeholders})",
tuple(secondary_ids),
)
updated = execute_query_single("SELECT * FROM ordre_drafts WHERE id = %s", (keep_id,))
_log_sync_event(
keep_id,
"drafts_consolidated",
primary.get("sync_status"),
primary.get("sync_status"),
{
"merged_from": draft_ids,
"kept_id": keep_id,
"customer_id": customer_id,
"line_count": len(merged_lines),
},
_get_user_id_from_request(http_request),
)
return {
"success": True,
"kept_draft_id": keep_id,
"merged_draft_ids": draft_ids,
"removed_draft_ids": secondary_ids,
"line_count": len(merged_lines),
"draft": updated,
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error consolidating ordre drafts: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to consolidate ordre drafts")

View File

@ -199,6 +199,15 @@
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sourceBadge(type) {
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
@ -335,9 +344,20 @@
const index = line.originalIndex;
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
onchange="ordreLines[${index}].description = this.value;">`
: (line.description || '-');
: escapeHtml(line.description || '-');
const manualActions = isManual
? `
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-outline-primary" onclick="resolveManualLineProductByCode(${index})" title="Søg produkt via strengkode i APIGateway">
<i class="bi bi-upc-scan"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>
</div>
`
: '-';
html += `
<tr class="order-lines-container" data-order="order-${groupIndex}">
@ -361,9 +381,7 @@
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${renderExportStatusBadge(line)}</td>
<td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
<td>${manualActions}</td>
</tr>
`;
});
@ -415,6 +433,62 @@
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
}
async function resolveManualLineProductByCode(index) {
const line = ordreLines[index];
if (!line) return;
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
if (!code || !code.trim()) return;
try {
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
if (customerId) {
params.append('customer_id', String(customerId));
}
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
credentials: 'include',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || 'APIGateway søgning fejlede');
}
if (!data.found || !data.product) {
alert('Ingen vare fundet på den strengkode');
return;
}
const product = data.product;
line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || '';
if (!Number(line.unit_price || 0) && product.sales_price != null) {
line.unit_price = Number(product.sales_price || 0);
}
if (product.ean) {
line.ean = product.ean;
}
line.product_code = code.trim();
updateLineAmount(index);
if (data.created) {
if (data.applied_customer_margin_percent != null) {
alert(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`);
} else {
alert('Produkt fundet i APIGateway og oprettet lokalt på linjen');
}
} else if (data.source === 'local') {
alert('Produkt fundet lokalt og sat på linjen');
} else {
alert('Produkt fundet via APIGateway og sat på linjen');
}
} catch (error) {
alert(error.message || 'Søgning fejlede');
}
}
function selectCustomer(customer) {
document.getElementById('customerId').value = customer.id;
document.getElementById('customerSearch').value = customer.name || '';

View File

@ -377,11 +377,10 @@
}
tbody.innerHTML = orderLines.map((line, index) => {
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
onchange="orderLines[${index}].description = this.value;">`
: (line.description || '-');
const isExportedLine = line.export_status === 'exported';
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
@ -390,28 +389,40 @@
? '<span class="badge bg-warning text-dark">Dry-run</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
const lineActions = isExportedLine
? '-'
: `
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-outline-primary" onclick="resolveDetailLineProductByCode(${index})" title="Søg produkt via strengkode i APIGateway">
<i class="bi bi-upc-scan"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>
</div>
`;
return `
<tr>
<td>${sourceBadge(line.source_type)}</td>
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${line.unit || 'stk'}</td>
<td>${statusBadge}</td>
<td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
<td>${lineActions}</td>
</tr>
`;
}).join('');
@ -461,6 +472,61 @@
renderLines();
}
async function resolveDetailLineProductByCode(index) {
const line = orderLines[index];
if (!line) return;
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
if (!code || !code.trim()) return;
try {
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
if (customerId) {
params.append('customer_id', String(customerId));
}
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
credentials: 'include',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || 'APIGateway søgning fejlede');
}
if (!data.found || !data.product) {
showToast('Ingen vare fundet på strengkoden', 'warning');
return;
}
const product = data.product;
line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || '';
if (!Number(line.unit_price || 0) && product.sales_price != null) {
line.unit_price = Number(product.sales_price || 0);
}
if (product.ean) {
line.ean = product.ean;
}
line.product_code = code.trim();
updateLineAmount(index);
if (data.created) {
if (data.applied_customer_margin_percent != null) {
showToast(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`, 'success');
} else {
showToast('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
}
} else if (data.source === 'local') {
showToast('Produkt fundet lokalt og tilknyttet linjen', 'success');
} else {
showToast('Produkt fundet via APIGateway og tilknyttet linjen', 'success');
}
} catch (error) {
showToast(error.message || 'Søgning fejlede', 'danger');
}
}
function normalizeOrderLine(line) {
// Handle e-conomic format (product.description, unitNetPrice, etc.)
if (line.product && line.product.description && !line.description) {

View File

@ -82,6 +82,7 @@
<option value="paid">paid</option>
</select>
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
<button class="btn btn-outline-secondary" onclick="consolidateSelectedByCustomer()"><i class="bi bi-collection me-1"></i>Konsolider valgte</button>
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
@ -400,6 +401,63 @@
}
}
async function consolidateSelectedByCustomer() {
const ids = Array.from(selectedOrderIds).map(Number).filter(Boolean);
if (ids.length < 2) {
showToast('Vælg mindst to ordrer for at konsolidere', 'warning');
return;
}
const selectedOrders = orders.filter(order => ids.includes(order.id));
const groups = {};
selectedOrders.forEach(order => {
const key = order.customer_id || 'none';
if (!groups[key]) groups[key] = [];
groups[key].push(order.id);
});
const eligibleGroups = Object.entries(groups)
.filter(([key, groupIds]) => key !== 'none' && groupIds.length >= 2);
if (!eligibleGroups.length) {
showToast('Ingen valgte ordrer kan konsolideres (kræver samme kunde)', 'warning');
return;
}
const totalDrafts = eligibleGroups.reduce((sum, [, groupIds]) => sum + groupIds.length, 0);
if (!confirm(`Konsolider ${totalDrafts} valgte ordrer fordelt på ${eligibleGroups.length} kunde-grupper?`)) {
return;
}
const results = [];
const failures = [];
for (const [customerId, groupIds] of eligibleGroups) {
try {
const res = await fetch('/api/v1/ordre/drafts/consolidate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draft_ids: groupIds }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Konsolidering fejlede');
results.push({ customerId, kept: data.kept_draft_id, merged: groupIds.length });
} catch (error) {
failures.push({ customerId, error: error.message });
}
}
await loadOrders();
if (results.length) {
showToast(`${results.length} kunde-grupper konsolideret`, 'success');
}
if (failures.length) {
const msg = failures.map(f => `Kunde ${f.customerId}: ${f.error}`).join(' | ');
showToast(msg, 'danger');
}
}
async function saveQuickSyncStatus(orderId) {
const select = document.getElementById(`syncStatus-${orderId}`);
const syncStatus = (select?.value || '').trim().toLowerCase();

View File

@ -3098,13 +3098,48 @@
}
/* V3 readability fix: keep menu structure unchanged, restore normal text sizing */
#caseTabs {
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
background: rgba(11, 53, 90, 0.36);
border-radius: 10px 10px 0 0;
padding: 0.2rem 0.3rem 0;
}
#caseTabs .nav-link {
font-size: 1rem;
font-weight: 500;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 0.58rem 0.9rem;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
#caseTabs .nav-link:hover,
#caseTabs .nav-link:focus-visible {
color: #ffffff;
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.28);
outline: none;
}
#caseTabs .nav-link.active {
color: #0f4c75;
background: #ffffff;
border-color: rgba(255, 255, 255, 0.78);
border-bottom-color: #ffffff;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
}
#caseTabs .nav-link i {
font-size: 1rem;
opacity: 0.95;
}
#caseTabs .nav-link.active i {
color: #0f4c75;
opacity: 1;
}
.case-detail-page-shell .header-bg .badge {
@ -3357,10 +3392,10 @@
{% endblock %}
{% block content %}
<div class="case-detail-page-shell" style="background-color: #f1f5f9; min-height: 100vh;">
<div class="case-detail-page-shell" style="background: linear-gradient(to right, #0f4c75, #1e3c72) 0 0 / 100% 420px no-repeat, #ffffff; min-height: 100vh; margin-top: -1.35rem;">
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -1rem;">
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;">
<div class="container-fluid px-3 px-xl-4 position-relative">
<div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row">
<div class="d-flex align-items-center gap-3">
@ -3391,7 +3426,7 @@
<div class="mb-1">
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="letter-spacing: -0.5px;">{{ case.titel }}</h2>
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="font-size: 1.55rem; letter-spacing: -0.35px;">{{ case.titel }}</h2>
<button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
</div>
<div id="sag-titel-editor" class="d-none">
@ -3415,7 +3450,7 @@
</div>
<!-- WIDGET DASHBOARD -->
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -1.25rem;">
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -0.95rem;">
<div class="row g-2 mb-3">
<!-- Widget 1: Klassifikation -->
@ -3426,7 +3461,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" onchange="saveCaseStatusFromTopbar()" style="width: 62%;">
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% for st in status_options %}
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
{% endfor %}
@ -3435,7 +3470,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" onchange="saveCaseTypeFromTopbar()" style="width: 62%;">
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
@ -3448,7 +3483,7 @@
<div class="d-flex justify-content-between align-items-center">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label>
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" onchange="saveCasePriorityFromTopbar()" style="width: 62%;">
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% set topbar_priority = (case.priority or 'normal')|lower %}
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
@ -3468,7 +3503,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label>
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<option value="">Ingen bruger</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
@ -3478,7 +3513,7 @@
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label>
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<option value="">Ingen gruppe</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
@ -3514,7 +3549,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label>
<div class="input-group input-group-sm" style="width: 62%;">
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}" onchange="saveCaseStartDateFromTopbar()">
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
@ -3531,7 +3566,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label>
<div class="input-group input-group-sm" style="width: 62%;">
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}" onchange="updateDeferredUntil(this.value || null)">
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
@ -3545,7 +3580,7 @@
<div class="d-flex justify-content-between align-items-center">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label>
<div class="input-group input-group-sm" style="width: 62%;">
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}" onchange="updateDeadline(this.value || null)">
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}">
<button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button>
</div>
</div>
@ -3568,8 +3603,8 @@
<div id="caseCustomerTopAlerts" class="mb-2 d-none"></div>
<div class="d-grid gap-2">
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseAnyDeskModal()"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
<button type="button" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseWorkOrderPrint()"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
<button type="button" id="topbarPrintWorkOrderBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
</div>
</div>
</div>
@ -3642,6 +3677,93 @@
window.forceCaseTabActivation(tabId);
return false;
};
(function bindTopWidgetActions() {
const localCaseId = Number({{ case.id }});
const patchCase = async (payload) => {
const response = await fetch(`/api/v1/sag/${localCaseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
};
const bindChange = (id, handler) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', async () => {
try {
await handler(el);
} catch (error) {
console.error(`Top widget change failed for ${id}`, error);
if (typeof showToast === 'function') {
showToast('Kunne ikke gemme ændringen', 'danger');
}
}
});
};
bindChange('topbarStatusSelect', async (el) => {
await patchCase({ status: el.value || 'åben' });
location.reload();
});
bindChange('topbarTypeSelect', async (el) => {
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
location.reload();
});
bindChange('topbarPrioritySelect', async (el) => {
await patchCase({ priority: String(el.value || 'normal').toLowerCase() });
location.reload();
});
bindChange('tabsAssignmentUserSelect', async (el) => {
const raw = String(el.value || '').trim();
await patchCase({ ansvarlig_bruger_id: raw ? Number(raw) : null });
location.reload();
});
bindChange('tabsAssignmentGroupSelect', async (el) => {
const raw = String(el.value || '').trim();
await patchCase({ assigned_group_id: raw ? Number(raw) : null });
location.reload();
});
bindChange('topbarStartDateInput', async (el) => {
await patchCase({ start_date: el.value || null });
location.reload();
});
bindChange('topbarDeferredInput', async (el) => {
await patchCase({ deferred_until: el.value || null });
location.reload();
});
bindChange('topbarDeadlineInput', async (el) => {
await patchCase({ deadline: el.value || null });
location.reload();
});
const printBtn = document.getElementById('topbarPrintWorkOrderBtn');
if (printBtn) {
printBtn.addEventListener('click', () => {
window.open(`/sag/${localCaseId}/work-orders/print`, '_blank', 'noopener');
});
}
const anydeskBtn = document.getElementById('caseAnyDeskOpenBtn');
if (anydeskBtn && typeof window.openCaseAnyDeskModal === 'function') {
anydeskBtn.addEventListener('click', () => {
window.openCaseAnyDeskModal();
});
}
})();
</script>
<!-- Tabs Navigation -->

View File

@ -3,7 +3,7 @@ import logging
import base64
import ipaddress
import re
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from urllib.error import URLError, HTTPError
from urllib.request import Request as UrlRequest, urlopen
@ -210,7 +210,7 @@ async def yealink_established(
def _is_external_number(value: Optional[str]) -> bool:
d = digits_only(value)
return 8 <= len(d) <= 15
return len(d) >= 8
def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool:
d = digits_only(value)
@ -221,21 +221,6 @@ async def yealink_established(
return True
return len(d) <= 6
def _strip_local_suffix(value: Optional[str], local_ext: Optional[str]) -> Optional[str]:
"""If a number is too long (> 15 E.164 digits), try stripping the local extension
from the end. This fixes Yealink Action URL misconfiguration where $caller$local
gets concatenated without a separator."""
if not value or not local_ext:
return value
d = digits_only(value)
local_d = digits_only(local_ext)
if len(d) > 15 and local_d and d.endswith(local_d):
stripped = d[: len(d) - len(local_d)]
if len(stripped) >= 4:
# Re-apply leading '+' if original had it
return ("+" + stripped) if value.lstrip().startswith("+") else stripped
return value
local_value = _sanitize(local) or _sanitize(active_user)
caller_value = _sanitize(caller) or _sanitize(remote)
callee_value = _sanitize(callee)
@ -243,12 +228,6 @@ async def yealink_established(
local_extension = extract_extension(local_value) or local_value
# Fix Yealink misconfiguration where caller number has the local phone number
# concatenated at the end (e.g. "$caller$local" without a separator in the URL template).
caller_value = _strip_local_suffix(caller_value, local_extension) or caller_value
if _sanitize(remote):
remote = _strip_local_suffix(_sanitize(remote), local_extension) or remote
is_outbound = False
if called_number_value and _is_external_number(called_number_value):
is_outbound = True
@ -674,29 +653,15 @@ async def list_calls(
where = []
params = []
parsed_date_from = None
parsed_date_to = None
if date_from:
try:
parsed_date_from = datetime.strptime(date_from, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=422, detail="Invalid date_from format, expected YYYY-MM-DD")
if date_to:
try:
# Make date_to inclusive for the whole selected day.
parsed_date_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid date_to format, expected YYYY-MM-DD")
if user_id is not None:
where.append("t.bruger_id = %s")
params.append(user_id)
if parsed_date_from is not None:
if date_from:
where.append("t.started_at >= %s")
params.append(parsed_date_from)
if parsed_date_to is not None:
where.append("t.started_at < %s")
params.append(parsed_date_to)
params.append(date_from)
if date_to:
where.append("t.started_at <= %s")
params.append(date_to)
if without_case:
where.append("t.sag_id IS NULL")

View File

@ -58,42 +58,8 @@
<th class="text-end">Varighed</th>
</tr>
</thead>
<tbody id="telefoniRows" data-initial-count="{{ initial_calls|length if initial_calls else 0 }}">
{% if initial_calls and initial_calls|length > 0 %}
{% for r in initial_calls %}
<tr>
<td>{{ r.started_at or '-' }}</td>
<td>{{ r.full_name or r.username or '-' }}</td>
<td>{{ 'Udgående' if r.direction == 'outbound' else 'Indgående' }}</td>
<td>{{ r.display_number or '-' }}</td>
<td>
{% if r.kontakt_id %}
<a href="/contacts/{{ r.kontakt_id }}">{{ r.contact_name or ('Kontakt #' ~ r.kontakt_id) }}</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if r.sag_id %}
<a href="/sag/{{ r.sag_id }}/v3">{{ r.sag_titel or ('Sag #' ~ r.sag_id) }}</a>
{% else %}
-
{% endif %}
</td>
<td class="text-end">
{% if r.duration_sec is not none %}
{{ r.duration_sec }}s
{% elif r.ended_at %}
-
{% else %}
I gang
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
{% endif %}
<tbody id="telefoniRows">
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -212,9 +178,6 @@ function escapeHtml(str) {
}
let telefoniCurrentUserId = null;
let telefoniAutoResetTried = false;
let telefoniFirstApiLoadDone = false;
let telefoniFiltersArmed = false;
const telefoniCallMap = new Map();
const linkSagState = {
callId: null,
@ -630,47 +593,6 @@ function openLinkContactModal(callId, mode = 'contact') {
}, 200);
}
function quickNewContact(callId, number) {
// Open the link-contact modal but scroll to / pre-fill the 'opret ny kontakt' form.
linkContactState.callId = Number(callId);
linkContactState.number = String(number || '').trim();
linkContactState.mode = 'contact';
const ctx = document.getElementById('linkContactContext');
const phoneInput = document.getElementById('newContactPhone');
const firstNameInput = document.getElementById('newContactFirstName');
const lastNameInput = document.getElementById('newContactLastName');
const emailInput = document.getElementById('newContactEmail');
const titleInput = document.getElementById('newContactTitle');
const searchInput = document.getElementById('linkContactSearch');
const companySearchInput = document.getElementById('linkCompanySearch');
if (ctx) ctx.textContent = `Opkald: ${linkContactState.number || ('#' + callId)}`;
if (phoneInput) phoneInput.value = linkContactState.number;
if (firstNameInput) firstNameInput.value = '';
if (lastNameInput) lastNameInput.value = '';
if (emailInput) emailInput.value = '';
if (titleInput) titleInput.value = '';
if (searchInput) searchInput.value = '';
if (companySearchInput) companySearchInput.value = '';
setLinkContactSelected(null, '');
setLinkCompanySelected(null, '');
renderLinkContactResults([]);
renderCompanyResults([]);
const modal = getLinkContactModalInstance();
if (modal) modal.show();
// Scroll to the "Opret ny kontakt" section after modal opens
setTimeout(() => {
const newSection = document.getElementById('newContactFirstName');
if (newSection) {
newSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
newSection.focus();
}
}, 300);
}
async function unlinkContact(callId) {
if (!confirm('Fjern link til kontakt for dette opkald?')) return;
try {
@ -900,32 +822,12 @@ async function loadUsers() {
async function loadCalls() {
const tbody = document.getElementById('telefoniRows');
const initialCount = Number(tbody?.dataset?.initialCount || '0');
const hadServerRows = Number.isFinite(initialCount) && initialCount > 0;
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
const userEl = document.getElementById('filterUser');
const fromEl = document.getElementById('filterFrom');
const toEl = document.getElementById('filterTo');
const withoutCaseEl = document.getElementById('filterWithoutCase');
let userId = userEl.value;
let from = fromEl.value;
let to = toEl.value;
let withoutCase = withoutCaseEl.checked;
// On first automatic load, ignore browser-restored filter values.
// Filters are only applied after explicit user interaction.
if (!telefoniFiltersArmed) {
userId = '';
from = '';
to = '';
withoutCase = false;
userEl.value = '';
fromEl.value = '';
toEl.value = '';
withoutCaseEl.checked = false;
}
const userId = document.getElementById('filterUser').value;
const from = document.getElementById('filterFrom').value;
const to = document.getElementById('filterTo').value;
const withoutCase = document.getElementById('filterWithoutCase').checked;
const qs = new URLSearchParams();
if (userId) qs.set('user_id', userId);
@ -944,34 +846,10 @@ async function loadCalls() {
telefoniCallMap.clear();
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
if (!rows || rows.length === 0) {
const hadFilters = Boolean(userId || from || to || withoutCase);
// If SSR already showed calls, avoid replacing them with an empty first auto-refresh.
if (!telefoniFirstApiLoadDone && hadServerRows && !hadFilters) {
telefoniFirstApiLoadDone = true;
return;
}
if (hadFilters && !telefoniAutoResetTried) {
telefoniAutoResetTried = true;
document.getElementById('filterUser').value = '';
document.getElementById('filterFrom').value = '';
document.getElementById('filterTo').value = '';
document.getElementById('filterWithoutCase').checked = false;
await loadCalls();
return;
}
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
telefoniFirstApiLoadDone = true;
return;
}
telefoniAutoResetTried = false;
telefoniFirstApiLoadDone = true;
if (!telefoniFiltersArmed) {
telefoniFiltersArmed = true;
}
tbody.innerHTML = rows.map(r => {
const started = r.started_at ? new Date(r.started_at) : null;
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
@ -988,25 +866,13 @@ async function loadCalls() {
const contactHtml = r.kontakt_id
? `<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary px-2 py-1" onclick="openLinkContactModal(${Number(r.id)})" title="Skift kontakt" aria-label="Skift kontakt">
<i class="bi bi-pencil-square"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger px-2 py-1" onclick="unlinkContact(${Number(r.id)})" title="Fjern kontakt-link" aria-label="Fjern kontakt-link">
<i class="bi bi-x-circle"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})">Skift</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${Number(r.id)})">Fjern</button>
</div>
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
: `<div class="d-flex gap-1 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1" onclick="quickNewContact(${Number(r.id)}, '${escapeHtml(numberRaw)}')" title="Ny kontakt" aria-label="Ny kontakt">
<i class="bi bi-person-plus"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary px-2 py-1" onclick="openLinkContactModal(${Number(r.id)})" title="Søg kontakt" aria-label="Søg kontakt">
<i class="bi bi-search"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-warning px-2 py-1" onclick="openLinkContactModal(${Number(r.id)}, 'company')" title="Knyt firma" aria-label="Knyt firma">
<i class="bi bi-building"></i>
</button>
</div>`;
: `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})" title="Vælg kontakt/firma">
<i class="bi bi-three-dots"></i>
</button>`;
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
const createQs = new URLSearchParams();
@ -1099,38 +965,22 @@ async function unlinkCase(callId) {
document.addEventListener('DOMContentLoaded', async () => {
initLinkContactModalEvents();
initLinkSagModalEvents();
const userFilter = document.getElementById('filterUser');
const fromFilter = document.getElementById('filterFrom');
const toFilter = document.getElementById('filterTo');
const withoutCaseFilter = document.getElementById('filterWithoutCase');
const tbody = document.getElementById('telefoniRows');
const ssrCount = Number(tbody?.dataset?.initialCount || '0');
if (userFilter) userFilter.value = '';
if (fromFilter) fromFilter.value = '';
if (toFilter) toFilter.value = '';
if (withoutCaseFilter) withoutCaseFilter.checked = false;
telefoniAutoResetTried = false;
// Filters are already cleared above so we can arm immediately.
telefoniFiltersArmed = true;
await loadUsers();
document.getElementById('btnRefresh').addEventListener('click', () => loadCalls());
document.getElementById('filterUser').addEventListener('change', () => loadCalls());
document.getElementById('filterFrom').addEventListener('change', () => loadCalls());
document.getElementById('filterTo').addEventListener('change', () => loadCalls());
document.getElementById('filterWithoutCase').addEventListener('change', () => loadCalls());
if (ssrCount > 0) {
// SSR already rendered rows - no need for an extra API round-trip.
// loadCalls() will fire when the user interacts with filters or Refresh.
telefoniFirstApiLoadDone = true;
return;
}
// SSR produced no rows (DB error or truly empty) - load via JS.
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
document.getElementById('filterUser').addEventListener('change', loadCalls);
document.getElementById('filterFrom').addEventListener('change', loadCalls);
document.getElementById('filterTo').addEventListener('change', loadCalls);
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
await loadCalls();
});
</script>

View File

@ -235,6 +235,178 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens:
return score
def _normalize_lookup_code(raw: Optional[str]) -> str:
return "".join(ch for ch in str(raw or "") if ch.isalnum()).lower()
def _find_local_product_by_lookup(code: str) -> Optional[Dict[str, Any]]:
normalized = _normalize_lookup_code(code)
if not normalized:
return None
query = """
SELECT *
FROM products
WHERE deleted_at IS NULL
AND (
LOWER(REGEXP_REPLACE(COALESCE(ean, ''), '[^a-zA-Z0-9]', '', 'g')) = %s
OR LOWER(REGEXP_REPLACE(COALESCE(sku_internal, ''), '[^a-zA-Z0-9]', '', 'g')) = %s
)
ORDER BY
CASE
WHEN status = 'active' THEN 0
ELSE 1
END,
id ASC
LIMIT 1
"""
return execute_query_single(query, (normalized, normalized))
def _pick_best_apigw_match(products: List[Dict[str, Any]], query: str) -> Optional[Dict[str, Any]]:
if not products:
return None
normalized_query = _normalize_lookup_code(query)
if not normalized_query:
return products[0]
exact_matches: List[Dict[str, Any]] = []
sku_matches: List[Dict[str, Any]] = []
for product in products:
ean_norm = _normalize_lookup_code(product.get("ean"))
sku_norm = _normalize_lookup_code(product.get("sku"))
if ean_norm and ean_norm == normalized_query:
exact_matches.append(product)
elif sku_norm and sku_norm == normalized_query:
sku_matches.append(product)
if exact_matches:
return exact_matches[0]
if sku_matches:
return sku_matches[0]
return products[0]
def _get_customer_margin_percent(customer_id: Optional[int]) -> Optional[float]:
if not customer_id:
return None
customer = execute_query_single(
"SELECT standard_margin_percent FROM customers WHERE id = %s",
(customer_id,),
)
if not customer:
return None
margin_raw = customer.get("standard_margin_percent")
if margin_raw is None:
return None
try:
return float(margin_raw)
except (TypeError, ValueError):
return None
def _calculate_sales_price_with_margin(base_price: Any, margin_percent: Optional[float]) -> Optional[float]:
if base_price is None:
return None
try:
base = float(base_price)
except (TypeError, ValueError):
return None
if margin_percent is None:
return base
price = base * (1 + (float(margin_percent) / 100.0))
return round(price, 2)
def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optional[int] = None) -> Dict[str, Any]:
product = payload.get("product") or payload
name = (product.get("product_name") or product.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="product_name is required")
supplier_code = product.get("supplier_code")
sku = product.get("sku")
sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku
if sku_internal:
existing_by_sku = execute_query_single(
"SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL",
(sku_internal,)
)
if existing_by_sku:
_upsert_product_supplier(existing_by_sku["id"], product, source="gateway")
return existing_by_sku
ean = (product.get("ean") or "").strip()
if ean:
existing_by_ean = execute_query_single(
"SELECT * FROM products WHERE ean = %s AND deleted_at IS NULL",
(ean,)
)
if existing_by_ean:
_upsert_product_supplier(existing_by_ean["id"], product, source="gateway")
return existing_by_ean
margin_percent = _get_customer_margin_percent(customer_id)
sales_price = _calculate_sales_price_with_margin(product.get("price"), margin_percent)
supplier_price = product.get("price")
insert_query = """
INSERT INTO products (
name,
short_description,
type,
status,
sku_internal,
ean,
manufacturer,
supplier_name,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
sales_price,
vat_rate,
billable
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
"""
params = (
name,
product.get("category"),
"hardware",
"active",
sku_internal,
ean or None,
product.get("manufacturer"),
product.get("supplier_name"),
sku,
supplier_price,
product.get("currency") or "DKK",
product.get("stock_qty"),
sales_price,
25.00,
True,
)
result = execute_query(insert_query, params)
created = result[0] if result else {}
if created:
_upsert_product_supplier(created["id"], product, source="gateway")
if created and margin_percent is not None:
created["applied_customer_margin_percent"] = margin_percent
return created
@router.get("/products/apigateway/search", response_model=Dict[str, Any])
async def search_apigw_products(
q: Optional[str] = Query(None),
@ -310,75 +482,93 @@ async def search_apigw_products(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/search/apigateway-sync", response_model=Dict[str, Any])
async def search_or_create_product_from_apigw(
code: str = Query(..., min_length=2),
auto_create: bool = Query(True),
customer_id: Optional[int] = Query(None),
):
"""
Local-first product lookup by EAN/SKU-like code.
If not found locally, search APIGateway and optionally auto-create locally from best match.
"""
search_code = (code or "").strip()
if not search_code:
raise HTTPException(status_code=400, detail="code is required")
local_product = _find_local_product_by_lookup(search_code)
if local_product:
return {
"found": True,
"source": "local",
"created": False,
"query": search_code,
"product": local_product,
}
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
url = f"{_apigw_base_url()}/api/v1/products/search"
params = {"q": search_code, "per_page": 25}
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=_apigw_headers(), params=params) as response:
if response.status >= 400:
detail = await _read_apigw_error(response)
raise HTTPException(
status_code=502,
detail=f"API Gateway product search failed ({response.status}): {detail}",
)
data = await response.json()
except HTTPException:
raise
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="API Gateway product search timed out")
except aiohttp.ClientError as e:
raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}")
except Exception as e:
logger.error("❌ APIGW sync search failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
products = data.get("products") if isinstance(data, dict) else []
products = products if isinstance(products, list) else []
best_match = _pick_best_apigw_match(products, search_code)
if not best_match:
return {
"found": False,
"source": "apigateway",
"created": False,
"query": search_code,
"message": "Ingen match i APIGateway",
}
if not auto_create:
return {
"found": True,
"source": "apigateway",
"created": False,
"query": search_code,
"product": best_match,
}
created_or_existing = _import_apigw_product_to_local(best_match, customer_id=customer_id)
applied_margin = created_or_existing.get("applied_customer_margin_percent") if isinstance(created_or_existing, dict) else None
return {
"found": True,
"source": "apigateway",
"created": True,
"query": search_code,
"customer_id": customer_id,
"applied_customer_margin_percent": applied_margin,
"product": created_or_existing,
}
@router.post("/products/apigateway/import", response_model=Dict[str, Any])
async def import_apigw_product(payload: Dict[str, Any]):
"""Import a single APIGW product into local catalog."""
try:
product = payload.get("product") or payload
name = (product.get("product_name") or product.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="product_name is required")
supplier_code = product.get("supplier_code")
sku = product.get("sku")
sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku
if sku_internal:
existing = execute_query_single(
"SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL",
(sku_internal,)
)
if existing:
_upsert_product_supplier(existing["id"], product, source="gateway")
return existing
sales_price = product.get("price")
supplier_price = product.get("price")
insert_query = """
INSERT INTO products (
name,
short_description,
type,
status,
sku_internal,
ean,
manufacturer,
supplier_name,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
sales_price,
vat_rate,
billable
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
"""
params = (
name,
product.get("category"),
"hardware",
"active",
sku_internal,
product.get("ean"),
product.get("manufacturer"),
product.get("supplier_name"),
sku,
supplier_price,
product.get("currency") or "DKK",
product.get("stock_qty"),
sales_price,
25.00,
True,
)
result = execute_query(insert_query, params)
created = result[0] if result else {}
if created:
_upsert_product_supplier(created["id"], product, source="gateway")
return created
return _import_apigw_product_to_local(payload)
except HTTPException:
raise
except Exception as e:

View File

@ -210,7 +210,7 @@
<div class="products-search">
<div class="input-group" style="min-width: 260px;">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter...">
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter eller EAN/strengkode...">
</div>
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
<option value="active" selected>Aktiv</option>
@ -234,6 +234,9 @@
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
<i class="bi bi-search"></i> Soeg
</button>
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)
</button>
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
<i class="bi bi-x"></i> Nulstil
</button>
@ -593,6 +596,67 @@ function applyLocalProductSearch() {
loadProducts();
}
function showLocalProductMessage(message, level = 'info') {
const meta = document.getElementById('localProductsMeta');
if (!meta) return;
const prefix = level === 'error' ? 'Fejl: ' : level === 'success' ? 'OK: ' : '';
meta.textContent = `${prefix}${message}`;
}
async function searchAndCreateByGatewayCode() {
const queryInput = document.getElementById('localSearchQuery');
const button = document.getElementById('gatewayBarcodeSyncBtn');
const code = queryInput ? queryInput.value.trim() : '';
if (!code) {
alert('Skriv EAN eller strengkode i soegefeltet foerst');
return;
}
const oldHtml = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Soeger...';
}
try {
const params = new URLSearchParams({ code, auto_create: 'true' });
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
credentials: 'include'
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.detail || 'Sogning i APIGateway fejlede');
}
if (!body.found) {
showLocalProductMessage('Ingen match i APIGateway for den strengkode', 'error');
alert('Ingen produkt fundet i APIGateway for den strengkode');
return;
}
if (body.source === 'local') {
showLocalProductMessage('Produkt fundet lokalt', 'success');
} else if (body.created) {
showLocalProductMessage('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
} else {
showLocalProductMessage('Produkt fundet via APIGateway', 'info');
}
await loadProducts();
} catch (error) {
console.error(error);
showLocalProductMessage(error.message || 'Sogning fejlede', 'error');
alert(error.message || 'Sogning fejlede');
} finally {
if (button) {
button.disabled = false;
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)';
}
}
}
function clearLocalProductSearch() {
const fields = [
'localSearchQuery',
@ -654,7 +718,11 @@ function setupLocalSearchEvents() {
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
applyLocalProductSearch();
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
searchAndCreateByGatewayCode();
} else {
applyLocalProductSearch();
}
}
});
el.addEventListener('input', triggerSearch);

View File

@ -813,7 +813,6 @@
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/support/fedex"><i class="bi bi-truck me-2"></i>FedEx Overblik</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
@ -846,7 +845,6 @@
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
@ -870,7 +868,6 @@
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard2"><i class="bi bi-check2-square me-2"></i>Godkend Tider V2</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>

View File

@ -0,0 +1,53 @@
<div class="modal fade" id="bugReportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-bug me-2"></i>Rapporter fejl</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
Screenshot tages automatisk af den aktuelle side.
</div>
<div class="mb-3">
<label for="bugActualInput" class="form-label">Hvad gik galt?</label>
<textarea id="bugActualInput" class="form-control" rows="4" maxlength="8000" placeholder="Beskriv problemet"></textarea>
</div>
<div class="mb-3">
<label for="bugExpectedInput" class="form-label">Hvad burde være sket?</label>
<textarea id="bugExpectedInput" class="form-control" rows="4" maxlength="8000" placeholder="Forventet adfærd"></textarea>
</div>
<div class="mb-3">
<label for="bugExtraFileInput" class="form-label">Ekstra fil (valgfri)</label>
<input id="bugExtraFileInput" type="file" class="form-control" />
</div>
<div class="mb-2">
<label class="form-label">Screenshot preview</label>
<div id="bugScreenshotPreviewWrap" class="border rounded p-2 bg-light text-center">
<span id="bugScreenshotPreviewPlaceholder" class="text-muted small">Ingen screenshot endnu</span>
<img id="bugScreenshotPreview" alt="Screenshot preview" style="display:none;max-width:100%;height:auto;border-radius:6px;" />
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="bugCaptureDisplayMediaBtn">
<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling
</button>
</div>
<div id="bugReportStatus" class="small text-muted mt-2"></div>
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Hvis det fejler, brug skærmdeling-knappen eller indsæt med Cmd+V.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="bugReportSubmitBtn">
<i class="bi bi-send me-1"></i>Send fejlrapport
</button>
</div>
</div>
</div>
</div>

View File

@ -254,15 +254,69 @@ class EconomicExportService:
(order['customer_id'],)
)
if not check_link or not check_link.get('hub_customer_id'):
raise HTTPException(
status_code=400,
detail=f"Customer '{order['customer_name']}' er ikke linket til en Hub kunde. Gå til vTiger kunder og link kunden først."
message = (
f"LOCAL-ONLY: Customer '{order['customer_name']}' er ikke linket til en Hub kunde. "
"Eksport til e-conomic er sprunget over."
)
logger.warning("⚠️ %s", message)
result = TModuleEconomicExportResult(
success=True,
dry_run=True,
order_id=request.order_id,
economic_draft_id=None,
economic_order_number=None,
message=message,
details={
"skipped_reason": "missing_hub_customer_link",
"order_number": order['order_number'],
"customer_name": order['customer_name'],
"read_only": self.read_only,
"dry_run": True,
}
)
audit.log_export_completed(
order_id=request.order_id,
economic_draft_id=None,
economic_order_number=None,
dry_run=True,
user_id=user_id,
)
return result
else:
raise HTTPException(
status_code=400,
detail=f"Customer '{order['customer_name']}' mangler e-conomic kundenummer. Opdater kunde i Customers modulet."
message = (
f"LOCAL-ONLY: Customer '{order['customer_name']}' mangler e-conomic kundenummer. "
"Eksport til e-conomic er sprunget over."
)
logger.warning("⚠️ %s", message)
result = TModuleEconomicExportResult(
success=True,
dry_run=True,
order_id=request.order_id,
economic_draft_id=None,
economic_order_number=None,
message=message,
details={
"skipped_reason": "missing_economic_customer_number",
"order_number": order['order_number'],
"customer_name": order['customer_name'],
"read_only": self.read_only,
"dry_run": True,
}
)
audit.log_export_completed(
order_id=request.order_id,
economic_draft_id=None,
economic_order_number=None,
dry_run=True,
user_id=user_id,
)
return result
customer_number = customer_data['economic_customer_number']

View File

@ -222,6 +222,9 @@
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" id="create-order-btn" onclick="createOrderFromApproved()">
<i class="bi bi-receipt"></i> Opret ordre
</button>
<button class="btn btn-danger" onclick="rejectSelected()">
<i class="bi bi-x-circle"></i> Afvis Valgte
</button>
@ -599,11 +602,20 @@
function updateSummary() {
const name = currentCustomerData?.customer_name || 'Kunde';
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
const approvedCount = parseInt(currentCustomerData?.approved_count || 0, 10);
const createOrderBtn = document.getElementById('create-order-btn');
document.getElementById('customer-name').textContent = name;
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
document.getElementById('pending-count').textContent = pendingEntries.length;
if (createOrderBtn) {
createOrderBtn.disabled = approvedCount <= 0;
createOrderBtn.title = approvedCount > 0
? `Opret ordre fra ${approvedCount} godkendte registreringer`
: 'Ingen godkendte registreringer endnu';
}
let totalValue = 0;
pendingEntries.forEach(entry => {
const hoursInput = document.getElementById(`hours-${entry.id}`);
@ -835,6 +847,51 @@
}
}
async function createOrderFromApproved() {
if (!currentCustomerId) {
alert('Vælg først en kunde.');
return;
}
const approvedCount = parseInt(currentCustomerData?.approved_count || 0, 10);
if (approvedCount <= 0) {
alert('Der er ingen godkendte registreringer at oprette ordre fra.');
return;
}
if (!confirm(`Opret ordre fra ${approvedCount} godkendte registreringer?`)) {
return;
}
const button = document.getElementById('create-order-btn');
const originalText = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
}
try {
const response = await fetch(`/api/v1/timetracking/orders/generate/${currentCustomerId}`, {
method: 'POST'
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.detail || 'Fejl ved oprettelse af ordre');
}
alert(`✅ Ordre oprettet: ${result.order_number}\n\nTotal: ${parseFloat(result.total_amount || 0).toFixed(2)} DKK`);
window.location.href = '/timetracking/orders';
} catch (error) {
alert('❌ Kunne ikke oprette ordre:\n\n' + error.message);
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalText;
}
}
}
async function rejectSelected() {
const ids = Array.from(selectedEntries);
if (ids.length === 0) return;

View File

@ -0,0 +1,90 @@
-- Fix malformed caller numbers for Per Krag's Yealink phone.
--
-- Run via:
-- podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d hubdb_v2 -f /dev/stdin < fix_telefoni_per_krag_numbers.sql
BEGIN;
-- STEP 1: Fix any remaining numbers that still have the 1857220892 suffix
-- (threshold lowered from >15 to >10 to catch shorter corrupted numbers)
UPDATE telefoni_opkald
SET ekstern_nummer = CASE
-- Too few remaining digits → NULL
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) <= 3
THEN NULL
-- 8 remaining digits = Danish number → +45 prefix
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
-- Other length → keep + prefix
ELSE '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
END
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 10;
-- STEP 2: Fix numbers that were previously half-patched: '+' + exactly 8 digits.
-- '+[8 digits]' is not valid E.164 (minimum 9 digits needed), so these are
-- Danish numbers that lost their +45 prefix during the first patch.
UPDATE telefoni_opkald
SET ekstern_nummer = '+45' || regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')
WHERE ekstern_nummer ~ '^\+[0-9]{8}$';
-- Verify: show any remaining suspicious numbers for this user
SELECT id, started_at, ekstern_nummer
FROM telefoni_opkald t
JOIN users u ON u.user_id = t.bruger_id
WHERE u.full_name ILIKE '%per krag%'
ORDER BY started_at DESC
LIMIT 20;
COMMIT;
-- Preview what will be changed
SELECT
id,
ekstern_nummer AS before,
CASE
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) < 4
THEN NULL
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
WHEN ekstern_nummer LIKE '+%'
THEN '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
ELSE left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
END AS after
FROM telefoni_opkald
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15
ORDER BY started_at DESC;
-- Apply the fix
UPDATE telefoni_opkald
SET ekstern_nummer = CASE
-- Strip last 10 digits (the local SIP suffix)
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) < 4
THEN NULL
-- 8 remaining digits = Danish number → prefix +45
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
-- Otherwise keep original + prefix
WHEN ekstern_nummer LIKE '+%'
THEN '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
ELSE left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
END
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15;
SELECT concat('Retter ', count(*), ' rækker') AS resultat
FROM telefoni_opkald
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15;
COMMIT;

View File

@ -139,8 +139,6 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
from app.modules.rentals.backend import router as rentals_api
from app.modules.task_templates.backend import router as task_templates_api
from app.economy.backend import router as economy_api
from app.economy.frontend import views as economy_views
# Configure logging
logging.basicConfig(
@ -459,7 +457,6 @@ app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bo
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@ -495,7 +492,6 @@ app.include_router(orders_views.router, tags=["Frontend"])
app.include_router(fedex_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"])
app.include_router(manual_views.router, tags=["Frontend"])
app.include_router(economy_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,15 @@
-- Track bug report submissions for rate limiting and auditing.
CREATE TABLE IF NOT EXISTS bug_report_submissions (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
screenshot_attached BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user_time
ON bug_report_submissions(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_sag
ON bug_report_submissions(sag_id);

View File

@ -0,0 +1,9 @@
-- Migration 184: Add optional relation to existing global contacts on location contacts
-- Date: 2026-02-18
-- Purpose: Allow location contact rows to reference contacts.id for explicit linkage
ALTER TABLE locations_contacts
ADD COLUMN IF NOT EXISTS related_contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_locations_contacts_related_contact_id
ON locations_contacts(related_contact_id);

View File

@ -0,0 +1,26 @@
-- Migration 185: Prevent duplicate active location-contact relations
-- Date: 2026-05-05
-- Purpose: Ensure one active relation per (location_id, related_contact_id)
-- Soft-delete duplicate active rows, keep best candidate (primary first, then oldest)
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY location_id, related_contact_id
ORDER BY is_primary DESC, created_at ASC, id ASC
) AS rn
FROM locations_contacts
WHERE deleted_at IS NULL
AND related_contact_id IS NOT NULL
)
UPDATE locations_contacts lc
SET deleted_at = NOW()
FROM ranked r
WHERE lc.id = r.id
AND r.rn > 1;
-- Enforce uniqueness for active linked contacts
CREATE UNIQUE INDEX IF NOT EXISTS uq_locations_contacts_location_related_active
ON locations_contacts(location_id, related_contact_id)
WHERE deleted_at IS NULL AND related_contact_id IS NOT NULL;

View File

@ -0,0 +1,16 @@
-- Migration 186: Customer-specific economic pricing fields
-- Adds customer-level defaults for margin, freight, supplier service flag, and invoice fee.
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS standard_margin_percent NUMERIC(5,2) NOT NULL DEFAULT 20.00,
ADD COLUMN IF NOT EXISTS special_freight_price NUMERIC(10,2),
ADD COLUMN IF NOT EXISTS supplier_service_enrolled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS invoice_fee_amount NUMERIC(10,2) NOT NULL DEFAULT 49.00;
CREATE INDEX IF NOT EXISTS idx_customers_supplier_service_enrolled
ON customers(supplier_service_enrolled);
COMMENT ON COLUMN customers.standard_margin_percent IS 'Default margin percentage used for order pricing.';
COMMENT ON COLUMN customers.special_freight_price IS 'Customer-specific freight price override.';
COMMENT ON COLUMN customers.supplier_service_enrolled IS 'Whether customer is enrolled in supplier service.';
COMMENT ON COLUMN customers.invoice_fee_amount IS 'Customer-specific invoice fee amount. 0 disables invoice fee.';

View File

@ -0,0 +1,7 @@
-- Migration 187: Customer-specific standard hourly rate
-- Allows setting default time price per customer on customer profile.
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS standard_hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 1200.00;
COMMENT ON COLUMN customers.standard_hourly_rate IS 'Customer-specific default hourly rate used in time-to-order pricing.';

489
static/js/bug-report.js Normal file
View File

@ -0,0 +1,489 @@
(function () {
const logs = [];
const MAX_LOGS = 200;
let bugModal = null;
let screenshotDataUrl = null;
let pendingScreenshotPromise = null;
let isCapturingDisplayMedia = false;
const pushLog = (type, args) => {
try {
logs.push({
type,
message: (args || []).map((x) => {
if (typeof x === 'string') return x;
try {
return JSON.stringify(x);
} catch (_) {
return String(x);
}
}).join(' '),
timestamp: new Date().toISOString()
});
if (logs.length > MAX_LOGS) {
logs.splice(0, logs.length - MAX_LOGS);
}
} catch (_) {
// no-op
}
};
['log', 'warn', 'error'].forEach((type) => {
const original = console[type];
console[type] = function (...args) {
pushLog(type, args);
original.apply(console, args);
};
});
window.addEventListener('error', (event) => {
logs.push({
type: 'error',
message: event.message,
url: event.filename,
line: event.lineno,
col: event.colno,
timestamp: new Date().toISOString()
});
if (logs.length > MAX_LOGS) {
logs.splice(0, logs.length - MAX_LOGS);
}
});
function getCurrentUser() {
const metaUser = document.querySelector('meta[name="user-id"]');
const userFromMeta = metaUser ? metaUser.getAttribute('content') : null;
let userFromToken = null;
const token = localStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
userFromToken = payload.sub || payload.user_id || null;
} catch (_) {
userFromToken = null;
}
}
return userFromToken || userFromMeta || null;
}
function getMetadata() {
return {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
screenSize: `${window.innerWidth}x${window.innerHeight}`,
user: getCurrentUser(),
};
}
async function toDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function isCrossOriginUrl(url) {
try {
if (!url) return false;
const parsed = new URL(url, window.location.href);
return parsed.origin !== window.location.origin;
} catch (_) {
return false;
}
}
function shouldIgnoreInScreenshot(el) {
if (!el || !el.tagName) return false;
const tag = String(el.tagName).toUpperCase();
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
return true;
}
if (tag === 'IMG') {
const src = el.getAttribute('src') || '';
return isCrossOriginUrl(src);
}
return false;
}
async function renderScreenshot(target, opts) {
const canvas = await window.html2canvas(target, opts);
return canvas.toDataURL('image/png');
}
async function takeScreenshotViaDisplayMedia() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
throw new Error('Display media API not supported');
}
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
try {
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
await new Promise((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error('Kunne ikke læse videostream'));
});
await video.play();
const width = Math.max(1, video.videoWidth || window.innerWidth);
const height = Math.max(1, video.videoHeight || window.innerHeight);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas context unavailable');
}
ctx.drawImage(video, 0, 0, width, height);
return canvas.toDataURL('image/png');
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
async function takeScreenshot() {
if (!window.html2canvas) {
throw new Error('Screenshot library not loaded');
}
const doc = document.documentElement;
const body = document.body;
const fullWidth = Math.max(
doc ? doc.scrollWidth : 0,
doc ? doc.offsetWidth : 0,
doc ? doc.clientWidth : 0,
body ? body.scrollWidth : 0,
body ? body.offsetWidth : 0,
window.innerWidth || 0
);
const fullHeight = Math.max(
doc ? doc.scrollHeight : 0,
doc ? doc.offsetHeight : 0,
doc ? doc.clientHeight : 0,
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
window.innerHeight || 0
);
const common = {
useCORS: true,
allowTaint: false,
logging: false,
scale: 1,
backgroundColor: '#ffffff',
imageTimeout: 3000,
ignoreElements: shouldIgnoreInScreenshot,
removeContainer: true,
};
// Strategy 1: Full page (most useful when it works)
try {
return await renderScreenshot(document.documentElement, {
...common,
width: fullWidth,
height: fullHeight,
windowWidth: fullWidth,
windowHeight: fullHeight,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
});
} catch (e1) {
console.warn('Bug report screenshot strategy 1 failed', e1);
if (String(e1?.message || '').toLowerCase().includes('unsupported color function "color"')) {
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
}
}
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
const contentRoot =
document.querySelector('.container-fluid.px-4.py-4') ||
document.querySelector('[data-bugreport-root]') ||
document.querySelector('#main-content') ||
document.querySelector('#content') ||
document.querySelector('main') ||
document.querySelector('.content-wrapper') ||
document.documentElement;
try {
return await renderScreenshot(contentRoot, {
...common,
width: Math.max(contentRoot.scrollWidth || 0, contentRoot.clientWidth || 0, window.innerWidth || 0),
height: Math.max(contentRoot.scrollHeight || 0, contentRoot.clientHeight || 0, window.innerHeight || 0),
scrollX: 0,
scrollY: 0,
});
} catch (e2) {
console.warn('Bug report screenshot strategy 2 failed', e2);
if (String(e2?.message || '').toLowerCase().includes('unsupported color function "color"')) {
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
}
}
throw new Error('Automatic screenshot failed');
}
function setStatus(text, isError) {
const el = document.getElementById('bugReportStatus');
if (!el) return;
el.textContent = text || '';
el.className = isError ? 'small text-danger mt-2' : 'small text-muted mt-2';
}
function setPreview(dataUrl) {
const img = document.getElementById('bugScreenshotPreview');
const placeholder = document.getElementById('bugScreenshotPreviewPlaceholder');
if (!img || !placeholder) return;
if (dataUrl) {
img.src = dataUrl;
img.style.display = '';
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.style.display = 'none';
placeholder.style.display = '';
}
}
async function handlePasteScreenshot(event) {
const modalVisible = document.getElementById('bugReportModal')?.classList.contains('show');
if (!modalVisible) return;
const items = Array.from(event.clipboardData?.items || []);
const imageItem = items.find((item) => item.kind === 'file' && item.type.startsWith('image/'));
if (!imageItem) return;
const blob = imageItem.getAsFile();
if (!blob) return;
event.preventDefault();
try {
const dataUrl = await toDataUrl(blob);
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot indsat fra clipboard.');
} catch (e) {
console.warn('Clipboard screenshot parse failed', e);
setStatus('Kunne ikke indsætte screenshot fra clipboard.', true);
}
}
async function openBugReportModal() {
if (!bugModal) {
const modalEl = document.getElementById('bugReportModal');
if (!modalEl || !window.bootstrap) return;
bugModal = new bootstrap.Modal(modalEl);
}
setStatus('Tager screenshot...');
screenshotDataUrl = null;
setPreview(null);
try {
screenshotDataUrl = pendingScreenshotPromise
? await pendingScreenshotPromise
: await takeScreenshot();
setPreview(screenshotDataUrl);
setStatus('Screenshot klar. Udfyld felterne og send.');
} catch (e) {
console.warn('Bug report screenshot failed', e);
setStatus('Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.', true);
} finally {
pendingScreenshotPromise = null;
}
bugModal.show();
}
function prepareScreenshotFromTrigger() {
pendingScreenshotPromise = takeScreenshot();
return pendingScreenshotPromise;
}
async function captureScreenshotViaDisplayMediaFromUserGesture() {
if (isCapturingDisplayMedia) return;
const btn = document.getElementById('bugCaptureDisplayMediaBtn');
const originalHtml = btn ? btn.innerHTML : '';
isCapturingDisplayMedia = true;
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Venter på skærmvalg...';
}
try {
const dataUrl = await takeScreenshotViaDisplayMedia();
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot taget via skærmdeling.');
} catch (e) {
console.warn('Bug report display media capture failed', e);
setStatus('Skærmdelings-screenshot mislykkedes. Prøv igen eller indsæt med Cmd+V.', true);
} finally {
isCapturingDisplayMedia = false;
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling';
}
}
}
async function submitBugReport() {
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
const fileInput = document.getElementById('bugExtraFileInput');
const submitBtn = document.getElementById('bugReportSubmitBtn');
if (!actual || !expected) {
setStatus('Udfyld både "Hvad gik galt" og "Hvad burde være sket".', true);
return;
}
let extraFileName = null;
let extraFileBase64 = null;
if (fileInput && fileInput.files && fileInput.files[0]) {
const f = fileInput.files[0];
extraFileName = f.name;
try {
extraFileBase64 = await toDataUrl(f);
} catch (_) {
setStatus('Kunne ikke læse ekstra fil.', true);
return;
}
}
const payload = {
actual,
expected,
screenshot_base64: screenshotDataUrl,
metadata: getMetadata(),
logs,
extra_file_name: extraFileName,
extra_file_base64: extraFileBase64,
};
const prevText = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender...';
}
try {
const endpoints = ['/api/v1/bug-reports', '/bug-reports'];
let res = null;
let data = {};
for (const endpoint of endpoints) {
res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
data = await res.json().catch(() => ({}));
// Try fallback endpoint only when path is missing.
if (res.status === 404) {
continue;
}
break;
}
if (!res || !res.ok) {
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
throw new Error(detail);
}
setStatus('Fejl rapporteret.');
const target = data.case_url || '/sag';
setTimeout(() => {
window.location.href = target;
}, 500);
} catch (e) {
setStatus(e.message || 'Kunne ikke sende fejlrapport', true);
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = prevText;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('bugReportBtn');
const submitBtn = document.getElementById('bugReportSubmitBtn');
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
const modalEl = document.getElementById('bugReportModal');
if (btn) {
btn.addEventListener('click', (e) => {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
openBugReportModal();
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => {
submitBugReport();
});
}
if (displayMediaBtn) {
displayMediaBtn.addEventListener('click', (e) => {
e.preventDefault();
captureScreenshotViaDisplayMediaFromUserGesture();
});
}
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
const actual = document.getElementById('bugActualInput');
const expected = document.getElementById('bugExpectedInput');
const fileInput = document.getElementById('bugExtraFileInput');
if (actual) actual.value = '';
if (expected) expected.value = '';
if (fileInput) fileInput.value = '';
screenshotDataUrl = null;
setPreview(null);
setStatus('');
});
}
document.addEventListener('paste', (e) => {
handlePasteScreenshot(e);
});
document.addEventListener('keydown', (e) => {
const isTyping = ['INPUT', 'TEXTAREA'].includes((e.target?.tagName || '').toUpperCase());
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
if (!pendingScreenshotPromise) {
prepareScreenshotFromTrigger();
}
openBugReportModal();
}
});
});
})();

View File

@ -28,9 +28,6 @@ fi
CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown")
CURRENT_DIR=$(pwd)
DEFAULT_STACK_NAME="prod"
if [ "$CURRENT_DIR" = "/srv/podman/bmc_hub_v2" ]; then
DEFAULT_STACK_NAME="v2"
fi
if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then
echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!"
@ -166,9 +163,14 @@ fi
# Guard against host port conflicts before attempting startup
POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
if podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" >/dev/null 2>&1; then
PORT_HOLDERS=$(podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" || true)
if [ -n "$PORT_HOLDERS" ]; then
echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)"
echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433"
if echo "$PORT_HOLDERS" | grep -q 'bmc-hub-postgres-prod' && [ "$STACK_NAME" != "prod" ]; then
echo " Tip: Aktiv prod-stack fundet. Kør med korrekt stack-navn:"
echo " STACK_NAME=prod ./updateto.sh $VERSION"
fi
podman ps --format 'table {{.Names}}\t{{.Ports}}'
exit 1
fi