Compare commits

..

18 Commits

Author SHA1 Message Date
Christian
770f822fc6 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching optional files.
- Integrated automatic screenshot capture functionality when the bug report modal is opened.
- Created a new API endpoint for submitting bug reports, including validation and rate limiting.
- Added database migration for tracking bug report submissions.
- Updated frontend scripts to handle bug report submissions and display status messages.
- Enhanced contact search functionality with improved error handling and backward compatibility.
- Introduced a new button in the UI for accessing the bug report modal.
2026-05-06 07:01:43 +02:00
Christian
71f6372496 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching files.
- Implemented backend API for creating bug reports, including rate limiting and metadata logging.
- Introduced a new database table to track bug report submissions for auditing purposes.
- Enhanced the frontend to capture screenshots automatically and allow manual file uploads.
- Added error handling and user feedback for the bug reporting process.
- Updated existing templates and scripts to integrate the new bug reporting functionality.
2026-05-05 19:13:54 +02:00
Christian
1a44baba62 hotfix: fix economy time-queue order link path to /ordre 2026-05-05 07:42:46 +02:00
Christian
03a1b79737 hotfix: robust local order creation with customer mapping fallback 2026-05-05 07:40:58 +02:00
Christian
e878336537 hotfix: replace legacy missing economic customer number error message 2026-05-05 07:37:16 +02:00
Christian
a5866132ab hotfix: skip economic export when customer number missing (local-only) 2026-05-05 07:33:01 +02:00
Christian
ebdb13168d fix: allow local order creation without economic dependency 2026-05-05 07:30:09 +02:00
Christian
4b5e154dc1 fix: enforce local-order-only flow in economy time queue 2026-05-05 07:24:45 +02:00
Christian
f6b78f93eb fix: show sanitized phone details in sag contact search results 2026-05-05 07:14:51 +02:00
Christian
1fe0611453 fix: show phone and mobile in sag v3 add-contact search results 2026-05-05 07:03:03 +02:00
Christian
0dcc6c4fdb ui: make telefoni row action buttons icon-only 2026-05-05 06:50:20 +02:00
Christian
86b3b3be15 feat: add direct Ny kontakt / Søg / Firma buttons on telefoni rows 2026-05-05 00:57:28 +02:00
Christian
31fa771626 fix: strip local phone suffix from overly long caller numbers (Yealink URL misconfiguration) 2026-05-05 00:29:13 +02:00
Christian
e4e35a1285 fix: skip auto-loadCalls when SSR already rendered telefoni rows 2026-05-05 00:22:02 +02:00
Christian
aa2aea555d hotfix: ignore restored telephony filters on first load 2026-05-05 00:10:42 +02:00
Christian
415abb058a hotfix: keep initial telephony rows on first empty refresh 2026-05-04 23:46:41 +02:00
Christian
b1a4342a9a hotfix: server-render initial telephony calls 2026-05-04 22:46:31 +02:00
Christian
93da2866dc hotfix: always run compose up after build 2026-05-04 22:34:16 +02:00
48 changed files with 3340 additions and 282 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 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) 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 (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY= FIRMAAPI_API_KEY=

View File

@ -44,6 +44,11 @@ API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
API_RELOAD=false 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 (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1 FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY= FIRMAAPI_API_KEY=

13
RELEASE_NOTES_v2.2.84.md Normal file
View File

@ -0,0 +1,13 @@
# Release Notes v2.2.84
Dato: 2026-05-04
## Hotfix
- Rettet logikfejl i `updateto.sh` hvor `podman-compose up -d` kunne blive sprunget over efter successfuld build.
- Scriptet bygger nu foerst, og starter derefter stacken i et separat trin med korrekt fejlhaandtering.
## Berorte filer
- `updateto.sh`
- `VERSION`

15
RELEASE_NOTES_v2.2.85.md Normal file
View File

@ -0,0 +1,15 @@
# Release Notes v2.2.85
Dato: 2026-05-04
## Hotfix
- Telefoni-siden (`/telefoni`) rendrer nu seneste opkald server-side ved page load (SSR fallback).
- Dette sikrer, at brugeren ser opkald med det samme, selv hvis browserens JS/rendering/filter-state fejler eller er cachet.
- Klient-side `loadCalls()` koerer stadig bagefter og opdaterer tabellen som foer.
## Berorte filer
- `app/modules/telefoni/frontend/views.py`
- `app/modules/telefoni/templates/log.html`
- `VERSION`

13
RELEASE_NOTES_v2.2.86.md Normal file
View File

@ -0,0 +1,13 @@
# Release Notes v2.2.86
Dato: 2026-05-04
## Hotfix
- Rettet Telefoni UI race-condition hvor server-renderede kald blev vist ved page load, men kunne blive overskrevet med tom liste efter ca. 1 sekund af foerste JS-refresh.
- Siden bevarer nu initialt viste kald, hvis foerste API-refresh uden aktive filtre returnerer tomt.
## Berorte filer
- `app/modules/telefoni/templates/log.html`
- `VERSION`

14
RELEASE_NOTES_v2.2.87.md Normal file
View File

@ -0,0 +1,14 @@
# Release Notes v2.2.87
Dato: 2026-05-05
## Hotfix
- Telefoni: Foerste auto-load ignorerer nu browser-restored filterfelter (dato/user/uden sag).
- Dette forhindrer at opkald vises ved load og derefter forsvinder efter ca. 1 sekund.
- Filtre aktiveres stadig normalt ved brugerens egen interaktion.
## Berorte filer
- `app/modules/telefoni/templates/log.html`
- `VERSION`

View File

@ -1 +1 @@
2.2.83 2.2.99

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.core.config import settings
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") 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", { return templates.TemplateResponse("customers/frontend/customer_detail.html", {
"request": request, "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-label">EAN-nummer</span>
<span class="info-value" id="ean">-</span> <span class="info-value" id="ean">-</span>
</div> </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"> <div class="info-row">
<span class="info-label">Spærret</span> <span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span> <span class="info-value" id="barred">-</span>
@ -1023,6 +1043,43 @@
<input type="text" class="form-control" id="editCity"> <input type="text" class="form-control" id="editCity">
</div> </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 --> <!-- Status -->
<div class="col-12 mt-4"> <div class="col-12 mt-4">
<div class="form-check form-switch"> <div class="form-check form-switch">
@ -1319,6 +1376,9 @@
<script> <script>
const customerId = parseInt(window.location.pathname.split('/').pop()); 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 customerData = null;
let pipelineStages = []; let pipelineStages = [];
let allTagsCache = []; let allTagsCache = [];
@ -1674,6 +1734,22 @@ function displayCustomer(customer) {
document.getElementById('vatZone').textContent = customer.vat_zone || '-'; document.getElementById('vatZone').textContent = customer.vat_zone || '-';
document.getElementById('currency').textContent = customer.currency_code || 'DKK'; document.getElementById('currency').textContent = customer.currency_code || 'DKK';
document.getElementById('ean').textContent = customer.ean || '-'; 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 document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>' ? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>'; : '<span class="badge bg-success">Nej</span>';
@ -3899,6 +3975,11 @@ function editCustomer() {
document.getElementById('editAddress').value = customerData.address || ''; document.getElementById('editAddress').value = customerData.address || '';
document.getElementById('editPostalCode').value = customerData.postal_code || ''; document.getElementById('editPostalCode').value = customerData.postal_code || '';
document.getElementById('editCity').value = customerData.city || ''; 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; document.getElementById('editIsActive').checked = customerData.is_active !== false;
// Show modal // Show modal
@ -3907,6 +3988,11 @@ function editCustomer() {
} }
async function saveCustomerEdit() { 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 = { const updateData = {
name: document.getElementById('editName').value, name: document.getElementById('editName').value,
cvr_number: document.getElementById('editCvrNumber').value || null, cvr_number: document.getElementById('editCvrNumber').value || null,
@ -3920,6 +4006,11 @@ async function saveCustomerEdit() {
address: document.getElementById('editAddress').value || null, address: document.getElementById('editAddress').value || null,
postal_code: document.getElementById('editPostalCode').value || null, postal_code: document.getElementById('editPostalCode').value || null,
city: document.getElementById('editCity').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 is_active: document.getElementById('editIsActive').checked
}; };

View File

@ -1,6 +1,8 @@
import logging import logging
from collections import defaultdict from collections import defaultdict
from datetime import date
from decimal import Decimal from decimal import Decimal
import json
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
@ -8,8 +10,6 @@ from pydantic import BaseModel, Field
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_insert, execute_query, execute_query_single, execute_update from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
from app.timetracking.backend.economic_export import economic_service
from app.timetracking.backend.models import TModuleEconomicExportRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -432,6 +432,259 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
return int(order_id) return int(order_id)
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
hub_customer_id = customer.get("hub_customer_id")
hub_customer = None
if hub_customer_id:
hub_customer = execute_query_single(
"""
SELECT
standard_hourly_rate,
standard_margin_percent,
special_freight_price,
supplier_service_enrolled,
invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
invoice_fee_amount = Decimal(
str(
(hub_customer or {}).get("invoice_fee_amount")
if (hub_customer or {}).get("invoice_fee_amount") is not None
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
)
)
special_freight_price = (hub_customer or {}).get("special_freight_price")
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
standard_margin_percent = Decimal(
str(
(hub_customer or {}).get("standard_margin_percent")
if (hub_customer or {}).get("standard_margin_percent") is not None
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
)
)
base_hourly_rate = Decimal(
str(
(hub_customer or {}).get("standard_hourly_rate")
if (hub_customer or {}).get("standard_hourly_rate") is not None
else hourly_rate
)
)
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
amount = (qty * unit_price).quantize(Decimal("0.01"))
line_payloads.append(
{
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
"source_type": "timequeue",
"source_id": ids[0] if ids else None,
"description": group["case_title"],
"quantity": float(qty),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "timer",
"product_id": None,
"selected": True,
"amount": float(amount),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": group["sag_id"],
"time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None,
"meta": {
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
"standard_margin_percent": float(effective_margin_percent),
},
}
)
if special_freight_amount > 0:
line_payloads.append(
{
"line_key": f"freight:{hub_customer_id or customer_id}",
"source_type": "freight",
"source_id": None,
"description": "Særlig fragtpris",
"quantity": 1.0,
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
}
)
# Fee line is included by default unless customer-specific value is 0.
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
"quantity": 1.0,
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
"meta": {
"standard_margin_percent": float(standard_margin_percent),
"supplier_service_enrolled": supplier_service_enrolled,
},
}
)
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
draft_title,
int(hub_customer_id) if hub_customer_id else None,
json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue",
1,
user_id,
json.dumps({}, ensure_ascii=False),
invoice_aggregate_key,
),
)
if not draft:
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
return int(draft["id"])
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
Accepts:
- direct tmodule customer id
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
"""
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
return int(row["id"]) if row else None
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
row = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
return int(row["id"]) if row else None
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct = _find_by_tmodule_id(cid)
if direct:
return direct
mapped = _find_by_hub_customer_id(cid)
if mapped:
return mapped
if sag_id is not None:
try:
sid = int(sag_id)
except (TypeError, ValueError):
sid = None
if sid and sid > 0:
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id:
mapped = _find_by_hub_customer_id(int(hub_customer_id))
if mapped:
return mapped
return None
@router.post("/time-queue/send-to-invoices") @router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request): async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
ids = _ensure_ids(payload.ids) ids = _ensure_ids(payload.ids)
@ -467,18 +720,14 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
if not rows: if not rows:
raise HTTPException(status_code=400, detail="No eligible entries found") raise HTTPException(status_code=400, detail="No eligible entries found")
# Ensure selected invoice candidates are approved and invoice-billable. # Local order creation must not depend on e-conomic data/mapping.
selected_invoice_ids = [ # Selected entries are converted to local orders regardless of billing method.
int(r["id"]) selected_order_ids = [int(r["id"]) for r in rows]
for r in rows
if bool(r.get("billable", True))
and (r.get("billing_method") or "invoice") == "invoice"
]
if not selected_invoice_ids: if not selected_order_ids:
raise HTTPException(status_code=400, detail="No selected entries are invoice-billable") raise HTTPException(status_code=400, detail="No selected entries found")
placeholders_invoice = ",".join(["%s"] * len(selected_invoice_ids)) placeholders_invoice = ",".join(["%s"] * len(selected_order_ids))
execute_update( execute_update(
f""" f"""
UPDATE tmodule_times UPDATE tmodule_times
@ -490,38 +739,67 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
WHERE id IN ({placeholders_invoice}) WHERE id IN ({placeholders_invoice})
AND status <> 'billed' AND status <> 'billed'
""", """,
tuple(selected_invoice_ids), tuple(selected_order_ids),
) )
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list) rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
skipped_missing_customer: List[int] = []
for row in rows: for row in rows:
if int(row["id"]) in selected_invoice_ids: if int(row["id"]) not in selected_order_ids:
rows_by_customer[int(row["customer_id"])].append(row) continue
export_results = [] resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not resolved_customer_id:
skipped_missing_customer.append(int(row["id"]))
continue
rows_by_customer[int(resolved_customer_id)].append(row)
created_drafts = []
failed_customers: List[Dict[str, Any]] = []
for cust_id, cust_rows in rows_by_customer.items(): for cust_id, cust_rows in rows_by_customer.items():
order_id = _create_order_from_selected(cust_id, cust_rows, user_id) try:
export_result = await economic_service.export_order( draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
TModuleEconomicExportRequest(order_id=order_id, force=False), created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
user_id=user_id, except HTTPException as ex:
) failed_customers.append(
export_results.append(
{ {
"customer_id": cust_id, "customer_id": cust_id,
"order_id": order_id, "entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
"success": bool(export_result.success), "error": str(ex.detail),
"dry_run": bool(export_result.dry_run),
"message": export_result.message,
"economic_draft_id": export_result.economic_draft_id,
"economic_order_number": export_result.economic_order_number,
} }
) )
if not created_drafts:
if skipped_missing_customer:
raise HTTPException(
status_code=400,
detail="No local orders created: selected entries are missing customer linkage",
)
if failed_customers:
raise HTTPException(
status_code=400,
detail="No local orders created: customer data is invalid for selected entries",
)
raise HTTPException(status_code=400, detail="No local orders created")
# Time queue must never push directly to e-conomic.
# Orders are created locally and can be transferred manually from Orders page.
draft_ids = [o["draft_id"] for o in created_drafts]
orders_url = "/ordre"
if len(draft_ids) == 1:
orders_url = f"/ordre/{draft_ids[0]}"
return { return {
"success": True, "success": True,
"selected": len(ids), "selected": len(ids),
"invoice_candidates": len(selected_invoice_ids), "order_candidates": len(selected_order_ids),
"exports": export_results, "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": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
} }
except HTTPException: except HTTPException:
raise raise

View File

@ -7,13 +7,13 @@
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3"> <div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
<div> <div>
<h2 class="mb-1">Economy Time Queue</h2> <h2 class="mb-1">Economy Time Queue</h2>
<p class="text-muted mb-0">Hub-created, non-billed time entries.</p> <p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
</div> </div>
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center"> <div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span> <span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button> <button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button> <button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
<button class="btn btn-success" id="sendInvoicesBtn">Send Selected To Invoices</button> <button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
</div> </div>
</div> </div>
@ -447,7 +447,7 @@
const ids = selectedIds(); const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry'); if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Send selected entries to invoices now?'); const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
if (!ok) return; if (!ok) return;
try { try {
@ -455,10 +455,19 @@
method: 'POST', method: 'POST',
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
}); });
const exports = (result.exports || []).map((x) => { const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
return `customer ${x.customer_id}, order ${x.order_id}, success=${x.success}, dry_run=${x.dry_run}`; const draftId = x.draft_id || x.order_id;
return `customer ${x.customer_id}, draft ${draftId}`;
}).join('\n'); }).join('\n');
alert(exports || 'No export result'); const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
: '';
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
await loadCustomers(); await loadCustomers();
await loadEntries(); await loadEntries();
} catch (err) { } catch (err) {

View File

@ -35,6 +35,11 @@ class CustomerUpdate(BaseModel):
mobile_phone: Optional[str] = None mobile_phone: Optional[str] = None
invoice_email: Optional[str] = None invoice_email: Optional[str] = None
is_active: Optional[bool] = 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): class Customer(CustomerBase):

View File

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

View File

@ -412,8 +412,85 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,) (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["hierarchy"] = hierarchy
location["children"] = children 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 # Query customers
customers = execute_query(""" customers = execute_query("""

View File

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

View File

@ -2,8 +2,210 @@
{% block title %}{{ location.name }} - BMC Hub{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid px-4 py-4"> <div class="container-fluid px-4 py-4 locations-detail-page">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4"> <nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb"> <ol class="breadcrumb">
@ -16,24 +218,10 @@
<!-- Header Section --> <!-- Header Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="case-hero">
<div> <div class="case-hero-identity">
<h1 class="h2 fw-700 mb-2">{{ location.name }}</h1> <div class="d-flex flex-wrap align-items-center gap-2">
{% if location.hierarchy %} <span class="case-id-chip">Lokation #{{ location.id }}</span>
<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">
{% set type_label = { {% set type_label = {
'kompleks': 'Kompleks', 'kompleks': 'Kompleks',
'bygning': 'Bygning', 'bygning': 'Bygning',
@ -56,23 +244,18 @@
'vehicle': '#8e44ad' 'vehicle': '#8e44ad'
}.get(location.location_type, '#6c757d') %} }.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 }} {{ type_label }}
</span> </span>
{% if location.parent_location_id and location.parent_location_name %} {% if location.is_active %}
<span class="text-muted small"> <span class="case-status-chip open">
<i class="bi bi-diagram-3 me-1"></i> <span class="case-status-dot"></span>Aktiv
<a href="/app/locations/{{ location.parent_location_id }}" class="text-decoration-none"> </span>
{{ location.parent_location_name }} {% else %}
</a> <span class="case-status-chip closed">
<span class="case-status-dot"></span>Inaktiv
</span> </span>
{% endif %} {% endif %}
{% if location.is_active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm"> <a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
@ -86,11 +269,37 @@
</a> </a>
</div> </div>
</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>
</div> </div>
<!-- Tabs Navigation --> <!-- 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"> <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"> <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 <i class="bi bi-info-circle me-2"></i>Oplysninger
@ -99,6 +308,7 @@
<li class="nav-item" role="presentation"> <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"> <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 <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> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -109,11 +319,13 @@
<li class="nav-item" role="presentation"> <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"> <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 <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> </button>
</li> </li>
<li class="nav-item" role="presentation"> <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"> <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 <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> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -124,6 +336,7 @@
<li class="nav-item" role="presentation"> <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"> <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 <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> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -301,7 +514,13 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1"> <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"> <p class="small text-muted mb-2">
{% if contact.role %}{{ contact.role }}{% endif %} {% if contact.role %}{{ contact.role }}{% endif %}
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %} {% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
@ -663,16 +882,21 @@
<form id="addContactForm"> <form id="addContactForm">
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="contactName" class="form-label">Navn *</label> <label for="existingContactSearch" class="form-label">Søg eksisterende kontakt</label>
<input type="text" class="form-control" id="contactName" required> <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>
<div class="mb-3"> <div id="selectedExistingContact" class="alert alert-info py-2 px-3 d-none" role="alert">
<label for="contactEmail" class="form-label">Email</label> <div class="d-flex justify-content-between align-items-center">
<input type="email" class="form-control" id="contactEmail"> <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>
</div>
<input type="hidden" id="existingContactId" value="">
<div class="mb-3"> <div class="mb-3">
<label for="contactPhone" class="form-label">Telefon</label> <label class="form-label">Kontaktoplysninger</label>
<input type="tel" class="form-control" id="contactPhone"> <div class="form-control-plaintext small text-muted" id="selectedContactMeta">Vælg en kontakt for at se email og telefon.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="contactRole" class="form-label">Rolle</label> <label for="contactRole" class="form-label">Rolle</label>
@ -781,6 +1005,129 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal')); const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}'; 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 // Delete location
document.getElementById('confirmDeleteBtn').addEventListener('click', function() { document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
@ -803,14 +1150,26 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// Add contact form // Add contact form
document.getElementById('addContactForm').addEventListener('submit', function(e) { addContactForm.addEventListener('submit', function(e) {
e.preventDefault(); 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 = { const contactData = {
location_id: locationId, location_id: locationId,
contact_name: document.getElementById('contactName').value, role: contactRoleInput.value,
contact_email: document.getElementById('contactEmail').value, existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
contact_phone: document.getElementById('contactPhone').value,
role: document.getElementById('contactRole').value,
is_primary: document.getElementById('isPrimaryContact').checked is_primary: document.getElementById('isPrimaryContact').checked
}; };
fetch(`/api/v1/locations/${locationId}/contacts`, { fetch(`/api/v1/locations/${locationId}/contacts`, {
@ -818,13 +1177,35 @@ document.addEventListener('DOMContentLoaded', function() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contactData) body: JSON.stringify(contactData)
}) })
.then(response => { .then(async response => {
if (response.ok) { if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
setTimeout(() => location.reload(), 300); 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 // Add service form

View File

@ -2,8 +2,91 @@
{% block title %}Lokaliteter - BMC Hub{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid px-4 py-4"> <div class="container-fluid px-4 py-4 locations-list-page">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4"> <nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb"> <ol class="breadcrumb">
@ -15,8 +98,41 @@
<!-- Header Section --> <!-- Header Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<h1 class="h2 fw-700 mb-2">Lokaliteter</h1> <div class="locations-hero p-3 p-lg-4">
<p class="text-muted small">Oversigt over alle lokationer og faciliteter</p> <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>
</div> </div>
@ -294,12 +410,32 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('locationSearch'); const searchInput = document.getElementById('locationSearch');
const visibleCount = document.getElementById('visibleCount'); 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 rows = Array.from(document.querySelectorAll('.location-row'));
const rowById = new Map(); const rowById = new Map();
const parentById = new Map(); const parentById = new Map();
const childrenById = 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 => { rows.forEach(row => {
const id = row.getAttribute('data-location-id'); const id = row.getAttribute('data-location-id');
const parentId = row.getAttribute('data-parent-id') || null; const parentId = row.getAttribute('data-parent-id') || null;
@ -374,6 +510,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
collapseAll(); collapseAll();
updateSummaryStats();
function toggleNode(targetId) { function toggleNode(targetId) {
const row = rowById.get(targetId); const row = rowById.get(targetId);
@ -442,6 +579,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (visibleCount) { if (visibleCount) {
visibleCount.textContent = String(visible); visibleCount.textContent = String(visible);
} }
if (statVisible) {
statVisible.textContent = String(visible);
}
} }
if (searchInput) { if (searchInput) {
@ -450,6 +590,14 @@ document.addEventListener('DOMContentLoaded', function() {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(applySearchFilter, 150); debounceTimer = setTimeout(applySearchFilter, 150);
}); });
document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement !== searchInput) {
e.preventDefault();
searchInput.focus();
searchInput.select();
}
});
} }
// Select all checkbox // Select all checkbox

View File

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

View File

@ -18,7 +18,7 @@ ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
class OrdreLineInput(BaseModel): class OrdreLineInput(BaseModel):
line_key: str line_key: str
source_type: str source_type: str
source_id: int source_id: Optional[int] = None
description: str description: str
quantity: float = Field(gt=0) quantity: float = Field(gt=0)
unit_price: float = Field(ge=0) unit_price: float = Field(ge=0)
@ -45,6 +45,10 @@ class OrdreDraftUpsertRequest(BaseModel):
layout_number: Optional[int] = None layout_number: Optional[int] = None
class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2)
def _safe_json_field(value: Any) -> Any: def _safe_json_field(value: Any) -> Any:
if value is None: if value is None:
return None return None
@ -572,3 +576,114 @@ async def delete_ordre_draft(draft_id: int, http_request: Request):
except Exception as e: except Exception as e:
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True) logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to delete ordre draft") 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)); 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) { function sourceBadge(type) {
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>'; 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>'; if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
@ -335,9 +344,20 @@
const index = line.originalIndex; const index = line.originalIndex;
const isManual = line.source_type === 'manual'; const isManual = line.source_type === 'manual';
const descriptionField = isManual 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;">` 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 += ` html += `
<tr class="order-lines-container" data-order="order-${groupIndex}"> <tr class="order-lines-container" data-order="order-${groupIndex}">
@ -361,9 +381,7 @@
</td> </td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td> <td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${renderExportStatusBadge(line)}</td> <td>${renderExportStatusBadge(line)}</td>
<td> <td>${manualActions}</td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
</tr> </tr>
`; `;
}); });
@ -415,6 +433,62 @@
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>'; 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) { function selectCustomer(customer) {
document.getElementById('customerId').value = customer.id; document.getElementById('customerId').value = customer.id;
document.getElementById('customerSearch').value = customer.name || ''; document.getElementById('customerSearch').value = customer.name || '';

View File

@ -377,11 +377,10 @@
} }
tbody.innerHTML = orderLines.map((line, index) => { tbody.innerHTML = orderLines.map((line, index) => {
const isManual = line.source_type === 'manual'; const isExportedLine = line.export_status === 'exported';
const descriptionField = isManual const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}" ${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">` onchange="orderLines[${index}].description = this.value;">`;
: (line.description || '-');
const exportStatus = line.export_status || '-'; const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported' const statusBadge = exportStatus === 'exported'
@ -390,28 +389,40 @@
? '<span class="badge bg-warning text-dark">Dry-run</span>' ? '<span class="badge bg-warning text-dark">Dry-run</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</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 ` return `
<tr> <tr>
<td>${sourceBadge(line.source_type)}</td> <td>${sourceBadge(line.source_type)}</td>
<td>${descriptionField}</td> <td>${descriptionField}</td>
<td style="min-width:100px;"> <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)}" <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});"> onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:120px;"> <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)}" <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});"> onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:110px;"> <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)}" <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});"> onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td> <td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${line.unit || 'stk'}</td> <td>${line.unit || 'stk'}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td> <td>${lineActions}</td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
</tr> </tr>
`; `;
}).join(''); }).join('');
@ -461,6 +472,61 @@
renderLines(); 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) { function normalizeOrderLine(line) {
// Handle e-conomic format (product.description, unitNetPrice, etc.) // Handle e-conomic format (product.description, unitNetPrice, etc.)
if (line.product && line.product.description && !line.description) { if (line.product && line.product.description && !line.description) {

View File

@ -82,6 +82,7 @@
<option value="paid">paid</option> <option value="paid">paid</option>
</select> </select>
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span> <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-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> <button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div> </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) { async function saveQuickSyncStatus(orderId) {
const select = document.getElementById(`syncStatus-${orderId}`); const select = document.getElementById(`syncStatus-${orderId}`);
const syncStatus = (select?.value || '').trim().toLowerCase(); const syncStatus = (select?.value || '').trim().toLowerCase();

View File

@ -4766,11 +4766,35 @@
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>'; resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
} else { } else {
resultsDiv.innerHTML = contacts.map(c => ` resultsDiv.innerHTML = contacts.map(c => `
${(() => {
const clean = (v) => {
if (v === null || v === undefined) return '';
const s = String(v).trim();
if (!s || s.toLowerCase() === 'null' || s.toLowerCase() === 'none') return '';
return s;
};
const firstName = clean(c.first_name);
const lastName = clean(c.last_name);
const fullName = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${c.id}`;
const email = clean(c.email);
const company = clean(c.user_company);
const phones = Array.from(new Set([
clean(c.mobile) ? `Mobil: ${clean(c.mobile)}` : '',
clean(c.phone) ? `Telefon: ${clean(c.phone)}` : '',
clean(c.user_company_phone) ? `Firma: ${clean(c.user_company_phone)}` : ''
].filter(Boolean)));
return `
<div class="list-group-item list-group-item-action" style="cursor: pointer;" <div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')"> onclick="addContact(${caseId}, ${c.id}, '${fullName.replace(/'/g, "\\'")}')">
<strong>${c.first_name} ${c.last_name}</strong> <strong>${fullName}</strong>
<div class="small text-muted">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div> <div class="small text-muted">${[email, company ? '(' + company + ')' : ''].filter(Boolean).join(' ')}</div>
<div class="small mt-1">
${phones.join(' · ') || '<span class="text-muted">Intet telefonnummer</span>'}
</div> </div>
</div>
`;
})()}
`).join(''); `).join('');
} }
} catch (err) { } catch (err) {

View File

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

View File

@ -26,20 +26,42 @@ async def search_customers(q: str = Query(..., min_length=2)):
async def search_contacts(q: str = Query(..., min_length=2)): async def search_contacts(q: str = Query(..., min_length=2)):
""" """
Autocomplete search for contacts. Autocomplete search for contacts.
Returns list of {id, first_name, last_name, email} Returns list of {id, first_name, last_name, email, phone, mobile, user_company, user_company_phone}
Supports: first name, last name, email, combined "Fornavn Efternavn", phone, mobile. Supports: first name, last name, email, combined "Fornavn Efternavn", phone, mobile.
""" """
sql = """ sql = """
SELECT id, first_name, last_name, email SELECT
FROM contacts c.id,
c.first_name,
c.last_name,
NULLIF(NULLIF(TRIM(c.email), ''), 'null') AS email,
NULLIF(NULLIF(TRIM(c.phone), ''), 'null') AS phone,
NULLIF(NULLIF(TRIM(c.mobile), ''), 'null') AS mobile,
(
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company,
(
SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company_phone
FROM contacts c
WHERE WHERE
first_name ILIKE %s c.first_name ILIKE %s
OR last_name ILIKE %s OR c.last_name ILIKE %s
OR email ILIKE %s OR c.email ILIKE %s
OR CONCAT(first_name, ' ', last_name) ILIKE %s OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
OR phone ILIKE %s OR c.phone ILIKE %s
OR mobile ILIKE %s OR c.mobile ILIKE %s
ORDER BY first_name ASC, last_name ASC ORDER BY c.first_name ASC, c.last_name ASC
LIMIT 20 LIMIT 20
""" """
term = f"%{q}%" term = f"%{q}%"

View File

@ -3,7 +3,7 @@ import logging
import base64 import base64
import ipaddress import ipaddress
import re import re
from datetime import datetime, timedelta from datetime import datetime
from typing import Optional from typing import Optional
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
from urllib.request import Request as UrlRequest, urlopen from urllib.request import Request as UrlRequest, urlopen
@ -653,29 +653,15 @@ async def list_calls(
where = [] where = []
params = [] 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: if user_id is not None:
where.append("t.bruger_id = %s") where.append("t.bruger_id = %s")
params.append(user_id) params.append(user_id)
if parsed_date_from is not None: if date_from:
where.append("t.started_at >= %s") where.append("t.started_at >= %s")
params.append(parsed_date_from) params.append(date_from)
if parsed_date_to is not None: if date_to:
where.append("t.started_at < %s") where.append("t.started_at <= %s")
params.append(parsed_date_to) params.append(date_to)
if without_case: if without_case:
where.append("t.sag_id IS NULL") where.append("t.sag_id IS NULL")

View File

@ -3,6 +3,7 @@ import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -11,4 +12,43 @@ templates = Jinja2Templates(directory="app")
@router.get("/telefoni", response_class=HTMLResponse) @router.get("/telefoni", response_class=HTMLResponse)
async def telefoni_log_page(request: Request): async def telefoni_log_page(request: Request):
return templates.TemplateResponse("modules/telefoni/templates/log.html", {"request": request}) initial_calls = []
try:
initial_calls = execute_query(
"""
SELECT
t.id,
t.direction,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS display_number,
t.started_at,
t.duration_sec,
t.ended_at,
u.full_name,
u.username,
t.kontakt_id,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name,
t.sag_id,
s.titel AS sag_titel
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
ORDER BY t.started_at DESC
LIMIT 50
""",
(),
) or []
except Exception as e:
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)
return templates.TemplateResponse(
"modules/telefoni/templates/log.html",
{
"request": request,
"initial_calls": initial_calls,
},
)

View File

@ -235,6 +235,178 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens:
return score 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]) @router.get("/products/apigateway/search", response_model=Dict[str, Any])
async def search_apigw_products( async def search_apigw_products(
q: Optional[str] = Query(None), q: Optional[str] = Query(None),
@ -310,75 +482,93 @@ async def search_apigw_products(
raise HTTPException(status_code=500, detail=str(e)) 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]) @router.post("/products/apigateway/import", response_model=Dict[str, Any])
async def import_apigw_product(payload: Dict[str, Any]): async def import_apigw_product(payload: Dict[str, Any]):
"""Import a single APIGW product into local catalog.""" """Import a single APIGW product into local catalog."""
try: try:
product = payload.get("product") or payload return _import_apigw_product_to_local(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
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -210,7 +210,7 @@
<div class="products-search"> <div class="products-search">
<div class="input-group" style="min-width: 260px;"> <div class="input-group" style="min-width: 260px;">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span> <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> </div>
<select class="form-select" id="localSearchStatus" style="max-width: 140px;"> <select class="form-select" id="localSearchStatus" style="max-width: 140px;">
<option value="active" selected>Aktiv</option> <option value="active" selected>Aktiv</option>
@ -234,6 +234,9 @@
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()"> <button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
<i class="bi bi-search"></i> Soeg <i class="bi bi-search"></i> Soeg
</button> </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()"> <button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
<i class="bi bi-x"></i> Nulstil <i class="bi bi-x"></i> Nulstil
</button> </button>
@ -593,6 +596,67 @@ function applyLocalProductSearch() {
loadProducts(); 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() { function clearLocalProductSearch() {
const fields = [ const fields = [
'localSearchQuery', 'localSearchQuery',
@ -654,8 +718,12 @@ function setupLocalSearchEvents() {
el.addEventListener('keydown', (event) => { el.addEventListener('keydown', (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
searchAndCreateByGatewayCode();
} else {
applyLocalProductSearch(); applyLocalProductSearch();
} }
}
}); });
el.addEventListener('input', triggerSearch); 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/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="/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="/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="/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="/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> <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 <i class="bi bi-currency-dollar me-2"></i>Økonomi
</a> </a>
<ul class="dropdown-menu mt-2"> <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="#">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="/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> <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"><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/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/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/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/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> <li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>

View File

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

View File

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

View File

@ -222,6 +222,9 @@
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button> <button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
</div> </div>
<div class="d-flex gap-2"> <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()"> <button class="btn btn-danger" onclick="rejectSelected()">
<i class="bi bi-x-circle"></i> Afvis Valgte <i class="bi bi-x-circle"></i> Afvis Valgte
</button> </button>
@ -599,11 +602,20 @@
function updateSummary() { function updateSummary() {
const name = currentCustomerData?.customer_name || 'Kunde'; const name = currentCustomerData?.customer_name || 'Kunde';
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE; 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('customer-name').textContent = name;
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2); document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
document.getElementById('pending-count').textContent = pendingEntries.length; 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; let totalValue = 0;
pendingEntries.forEach(entry => { pendingEntries.forEach(entry => {
const hoursInput = document.getElementById(`hours-${entry.id}`); 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() { async function rejectSelected() {
const ids = Array.from(selectedEntries); const ids = Array.from(selectedEntries);
if (ids.length === 0) return; if (ids.length === 0) return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -28,9 +28,6 @@ fi
CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown") CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown")
CURRENT_DIR=$(pwd) CURRENT_DIR=$(pwd)
DEFAULT_STACK_NAME="prod" DEFAULT_STACK_NAME="prod"
if [ "$CURRENT_DIR" = "/srv/podman/bmc_hub_v2" ]; then
DEFAULT_STACK_NAME="v2"
fi
if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then
echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!" echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!"
@ -166,9 +163,14 @@ fi
# Guard against host port conflicts before attempting startup # Guard against host port conflicts before attempting startup
POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}" POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_PORT="${POSTGRES_PORT:-5432}"
if podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" >/dev/null 2>&1; then PORT_HOLDERS=$(podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" || true)
if [ -n "$PORT_HOLDERS" ]; then
echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)" echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)"
echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433" echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433"
if echo "$PORT_HOLDERS" | grep -q 'bmc-hub-postgres-prod' && [ "$STACK_NAME" != "prod" ]; then
echo " Tip: Aktiv prod-stack fundet. Kør med korrekt stack-navn:"
echo " STACK_NAME=prod ./updateto.sh $VERSION"
fi
podman ps --format 'table {{.Names}}\t{{.Ports}}' podman ps --format 'table {{.Names}}\t{{.Ports}}'
exit 1 exit 1
fi fi
@ -196,7 +198,13 @@ podman-compose -f "$PODMAN_COMPOSE_FILE" down
# Pull/rebuild with new version # Pull/rebuild with new version
echo "" echo ""
echo "🔨 Bygger nyt image med version $VERSION (--no-cache for at sikre ny kode fra Gitea)..." echo "🔨 Bygger nyt image med version $VERSION (--no-cache for at sikre ny kode fra Gitea)..."
if ! podman-compose -f "$PODMAN_COMPOSE_FILE" build --no-cache && podman-compose -f "$PODMAN_COMPOSE_FILE" up -d; then if ! podman-compose -f "$PODMAN_COMPOSE_FILE" build --no-cache; then
echo "❌ Fejl: podman-compose build fejlede"
echo " Tjek logs med: podman-compose -f $PODMAN_COMPOSE_FILE logs --tail=200"
exit 1
fi
if ! podman-compose -f "$PODMAN_COMPOSE_FILE" up -d; then
echo "❌ Fejl: podman-compose up fejlede" echo "❌ Fejl: podman-compose up fejlede"
echo " Tjek logs med: podman-compose -f $PODMAN_COMPOSE_FILE logs --tail=200" echo " Tjek logs med: podman-compose -f $PODMAN_COMPOSE_FILE logs --tail=200"
exit 1 exit 1