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.
This commit is contained in:
Christian 2026-05-06 07:01:43 +02:00
parent 71f6372496
commit 770f822fc6
31 changed files with 1994 additions and 513 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

@ -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

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,203 @@ 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.
@ -556,12 +755,12 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
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():
try:
order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
created_orders.append({"customer_id": cust_id, "order_id": order_id})
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(
{
@ -571,7 +770,7 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
}
)
if not created_orders:
if not created_drafts:
if skipped_missing_customer:
raise HTTPException(
status_code=400,
@ -586,20 +785,21 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
# 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]
draft_ids = [o["draft_id"] for o in created_drafts]
orders_url = "/ordre"
if len(order_ids) == 1:
orders_url = f"/ordre/{order_ids[0]}"
if len(draft_ids) == 1:
orders_url = f"/ordre/{draft_ids[0]}"
return {
"success": True,
"selected": len(ids),
"order_candidates": len(selected_order_ids),
"created_orders": created_orders,
"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,18 +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 failedCustomers = (result.failed_customers || []);
const orderMessage = orders || 'Ingen ordrer oprettet';
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(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
: '';
alert(`Lokale ordrer oprettet:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
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

@ -1200,12 +1200,12 @@ async def create_contact(location_id: int, data: ContactCreate):
location_name = location_check[0]['name']
contact_name = (data.contact_name or "").strip()
contact_email = _none_if_empty(data.contact_email)
contact_phone = _none_if_empty(data.contact_phone)
contact_role = _none_if_empty(data.role)
if not data.existing_contact_id:
raise HTTPException(
status_code=400,
detail="Du skal vælge en eksisterende kontakt"
)
if data.existing_contact_id:
existing_contact_query = """
SELECT id, first_name, last_name, email, phone, mobile, title
FROM contacts
@ -1220,22 +1220,69 @@ async def create_contact(location_id: int, data: ContactCreate):
)
existing_contact = existing_contact_result[0]
existing_full_name = (
contact_name = (
f"{(existing_contact.get('first_name') or '').strip()} "
f"{(existing_contact.get('last_name') or '').strip()}"
).strip()
if not contact_name:
contact_name = existing_full_name
if not contact_email:
contact_email = _none_if_empty(existing_contact.get('email'))
if not contact_phone:
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
if not contact_role:
contact_role = _none_if_empty(existing_contact.get('title'))
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="contact_name is required")
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:
@ -1246,13 +1293,43 @@ async def create_contact(location_id: int, data: ContactCreate):
"""
execute_query(unset_primary_query, (location_id,))
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
"""
))
# 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, updated_at
role, is_primary, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, NOW(), NOW())
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING *
"""
@ -1262,7 +1339,7 @@ async def create_contact(location_id: int, data: ContactCreate):
contact_email,
contact_phone,
contact_role,
data.is_primary
data.is_primary,
)
result = execute_query(insert_query, params)

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,14 +117,19 @@ class ContactBase(BaseModel):
)
class ContactCreate(ContactBase):
"""Request model for creating contact"""
is_primary: bool = Field(False, description="Set as primary contact for location")
existing_contact_id: Optional[int] = Field(
None,
class ContactCreate(BaseModel):
"""Request model for linking an existing global contact to a location"""
existing_contact_id: int = Field(
...,
ge=1,
description="Optional ID of an existing global contact to copy data from"
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")
class ContactUpdate(BaseModel):
@ -140,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,23 +244,18 @@
'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>
<div class="d-flex gap-2">
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
@ -86,11 +269,37 @@
</a>
</div>
</div>
<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

@ -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

@ -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 %}
<tbody id="telefoniRows">
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
{% endif %}
</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,
@ -349,34 +312,20 @@ function renderLinkContactResults(results) {
async function searchContacts(query) {
const token = ++linkContactState.searchToken;
const container = document.getElementById('linkContactResults');
const trimmed = String(query || '').trim();
if (trimmed.length < 2) {
renderLinkContactResults([]);
return;
}
if (container) {
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const qs = new URLSearchParams({ q: trimmed });
let res = await fetch(`/api/v1/search/contacts?${qs.toString()}`, { credentials: 'include' });
// Backward compatible fallback in case search router is unavailable.
if (!res.ok && (res.status === 404 || res.status === 405)) {
const legacyQs = new URLSearchParams({ search: trimmed, limit: '30', offset: '0' });
res = await fetch(`/api/v1/contacts?${legacyQs.toString()}`, { credentials: 'include' });
}
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
if (token !== linkContactState.searchToken) return;
if (!res.ok) {
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
return;
}
const data = await res.json();
const results = Array.isArray(data) ? data : (Array.isArray(data?.contacts) ? data.contacts : []);
const results = Array.isArray(data?.contacts) ? data.contacts : [];
renderLinkContactResults(results);
} catch (e) {
if (token !== linkContactState.searchToken) return;
@ -644,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 {
@ -914,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);
@ -958,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') : '-';
@ -1002,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();
@ -1113,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,8 +718,12 @@ function setupLocalSearchEvents() {
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
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>
@ -882,9 +879,6 @@
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="bugReportBtn" style="background: var(--accent-light); color: var(--accent);" title="Rapporter fejl (Ctrl+Shift+B)">
<i class="bi bi-bug"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
<i class="bi bi-question-circle"></i>
</button>
@ -1266,14 +1260,12 @@ window.addEventListener('unhandledrejection', function(event) {
});
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.23"></script>
<script src="/static/js/bug-report.js?v=1.6"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
@ -2023,9 +2015,6 @@ window.addEventListener('unhandledrejection', function(event) {
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Bug Report Modal -->
{% include ["bug_report_modal.html", "shared/frontend/bug_report_modal.html"] ignore missing %}
<!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}

View File

@ -33,8 +33,14 @@
</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. Du kan også indsætte med Cmd+V.</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>

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;

48
main.py
View File

@ -139,9 +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
from app.bug_reports.backend import router as bug_reports_api
# Configure logging
logging.basicConfig(
@ -155,34 +152,6 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
class _AccessLogPathFilter(logging.Filter):
"""Filter out high-frequency access log noise for specific polling endpoints."""
def filter(self, record: logging.LogRecord) -> bool:
try:
target = "/api/v1/bottom-bar/state"
# Match rendered access message (works across uvicorn format variations).
message = record.getMessage() or ""
if f"GET {target}" in message or f'"GET {target}' in message:
return False
# Fallback: match structured args when available.
args = record.args if isinstance(record.args, tuple) else ()
if len(args) >= 3:
method = str(args[1])
path = str(args[2])
if method == "GET" and path.startswith(target):
return False
except Exception:
# Never break logging on filter errors.
return True
return True
logging.getLogger("uvicorn.access").addFilter(_AccessLogPathFilter())
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle management - startup and shutdown"""
@ -488,8 +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"])
app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@ -525,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
@ -546,20 +512,8 @@ async def health_check():
if __name__ == "__main__":
import uvicorn
from copy import deepcopy
from uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG
import os
log_config = deepcopy(UVICORN_LOGGING_CONFIG)
log_config.setdefault("filters", {})["suppress_bottom_bar_polling"] = {
"()": _AccessLogPathFilter,
}
access_handler = log_config.get("handlers", {}).get("access", {})
existing_filters = list(access_handler.get("filters", []))
if "suppress_bottom_bar_polling" not in existing_filters:
existing_filters.append("suppress_bottom_bar_polling")
access_handler["filters"] = existing_filters
# Only enable reload in local development (not in Docker) - check both variables
enable_reload = (
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
@ -575,7 +529,6 @@ if __name__ == "__main__":
reload_includes=["*.py"],
reload_dirs=["app"],
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
log_config=log_config,
log_level="info"
)
else:
@ -587,6 +540,5 @@ if __name__ == "__main__":
workers=2,
timeout_keep_alive=65,
access_log=True,
log_config=log_config,
log_level="info"
)

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.';

View File

@ -4,6 +4,7 @@
let bugModal = null;
let screenshotDataUrl = null;
let pendingScreenshotPromise = null;
let isCapturingDisplayMedia = false;
const pushLog = (type, args) => {
try {
@ -206,6 +207,9 @@
});
} 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)
@ -227,6 +231,9 @@
});
} 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');
@ -296,7 +303,7 @@
setStatus('Screenshot klar. Udfyld felterne og send.');
} catch (e) {
console.warn('Bug report screenshot failed', e);
setStatus('Kunne ikke tage screenshot automatisk. Vedhæft et billede manuelt eller indsæt med Cmd+V.', true);
setStatus('Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.', true);
} finally {
pendingScreenshotPromise = null;
}
@ -305,17 +312,39 @@
}
function prepareScreenshotFromTrigger() {
pendingScreenshotPromise = (async () => {
try {
return await takeScreenshot();
} catch (e) {
console.warn('Bug report html2canvas capture failed, trying display media', e);
return await takeScreenshotViaDisplayMedia();
}
})();
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();
@ -401,6 +430,7 @@
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) {
@ -419,6 +449,13 @@
});
}
if (displayMediaBtn) {
displayMediaBtn.addEventListener('click', (e) => {
e.preventDefault();
captureScreenshotViaDisplayMediaFromUserGesture();
});
}
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
const actual = document.getElementById('bugActualInput');