Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
770f822fc6 | ||
|
|
71f6372496 | ||
|
|
1a44baba62 | ||
|
|
03a1b79737 | ||
|
|
e878336537 | ||
|
|
a5866132ab | ||
|
|
ebdb13168d | ||
|
|
4b5e154dc1 | ||
|
|
f6b78f93eb | ||
|
|
1fe0611453 | ||
|
|
0dcc6c4fdb | ||
|
|
86b3b3be15 | ||
|
|
31fa771626 | ||
|
|
e4e35a1285 | ||
|
|
aa2aea555d |
@ -16,6 +16,11 @@ API_HOST=0.0.0.0
|
||||
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
|
||||
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
|
||||
|
||||
# Customer default economics (used as fallback defaults in customer detail)
|
||||
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
|
||||
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
|
||||
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
|
||||
|
||||
# FirmaAPI (CVR company lookup)
|
||||
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||
FIRMAAPI_API_KEY=
|
||||
|
||||
@ -44,6 +44,11 @@ API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_RELOAD=false
|
||||
|
||||
# Customer default economics (used as fallback defaults in customer detail)
|
||||
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
|
||||
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
|
||||
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
|
||||
|
||||
# FirmaAPI (CVR company lookup)
|
||||
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||
FIRMAAPI_API_KEY=
|
||||
|
||||
14
RELEASE_NOTES_v2.2.87.md
Normal file
14
RELEASE_NOTES_v2.2.87.md
Normal 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`
|
||||
0
app/bug_reports/__init__.py
Normal file
0
app/bug_reports/__init__.py
Normal file
0
app/bug_reports/backend/__init__.py
Normal file
0
app/bug_reports/backend/__init__.py
Normal file
20
app/bug_reports/backend/models.py
Normal file
20
app/bug_reports/backend/models.py
Normal 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
|
||||
212
app/bug_reports/backend/router.py
Normal file
212
app/bug_reports/backend/router.py
Normal 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",
|
||||
)
|
||||
@ -160,8 +160,8 @@
|
||||
.contacts-table-wrap {
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
max-height: min(68vh, 780px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
@ -813,6 +813,7 @@ let searchQuery = '';
|
||||
let totalContacts = 0;
|
||||
let searchTimeout = null;
|
||||
let currentRequestController = null;
|
||||
let lastLoadedQueryKey = '';
|
||||
let availableCompanies = [];
|
||||
let selectedCompanyIds = new Set();
|
||||
let currentContactsData = [];
|
||||
@ -940,6 +941,12 @@ async function loadContacts() {
|
||||
params.append('is_active', 'false');
|
||||
}
|
||||
|
||||
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
|
||||
if (queryKey === lastLoadedQueryKey) {
|
||||
return;
|
||||
}
|
||||
lastLoadedQueryKey = queryKey;
|
||||
|
||||
const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal });
|
||||
const data = await response.json();
|
||||
|
||||
@ -982,11 +989,9 @@ function displayContacts(contacts) {
|
||||
|
||||
const companyCount = contact.company_count || 0;
|
||||
const companyNames = contact.company_names || [];
|
||||
const fallbackCompany = (contact.user_company || '').trim();
|
||||
const companyDisplay = companyNames.length > 0
|
||||
const companyDisplay = companyNames.length > 0
|
||||
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
|
||||
: (fallbackCompany || '-');
|
||||
const effectiveCompanyCount = companyCount > 0 ? companyCount : (fallbackCompany ? 1 : 0);
|
||||
: '-';
|
||||
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
||||
const preferredPhone = contact.mobile || contact.phone || '';
|
||||
const hasEmail = !!contact.email;
|
||||
@ -996,7 +1001,7 @@ function displayContacts(contacts) {
|
||||
const safeEmail = escapeHtml(contact.email || '-');
|
||||
const safeTitle = escapeHtml(contact.title || '-');
|
||||
const safePhone = escapeHtml(preferredPhone || '-');
|
||||
const companiesTitle = escapeHtml(companyNames.length ? companyNames.join(', ') : fallbackCompany);
|
||||
const companiesTitle = escapeHtml(companyNames.join(', '));
|
||||
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
|
||||
|
||||
return `
|
||||
@ -1035,7 +1040,7 @@ function displayContacts(contacts) {
|
||||
<td class="text-muted col-title">${safeTitle}</td>
|
||||
<td class="col-companies">
|
||||
<span class="company-count-chip" title="${companiesTitle}">
|
||||
<i class="bi bi-building"></i>${effectiveCompanyCount}
|
||||
<i class="bi bi-building"></i>${companyCount}
|
||||
</span>
|
||||
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
||||
</td>
|
||||
|
||||
@ -160,6 +160,11 @@ class Settings(BaseSettings):
|
||||
TIMETRACKING_AUTO_ROUND: bool = True
|
||||
TIMETRACKING_ROUND_INCREMENT: float = 0.5
|
||||
TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
|
||||
|
||||
# Customer economic defaults
|
||||
CUSTOMER_DEFAULT_MARGIN_PERCENT: float = 20.0
|
||||
CUSTOMER_DEFAULT_INVOICE_FEE: float = 49.0
|
||||
CUSTOMER_DEFAULT_HOURLY_RATE: float = 1200.0
|
||||
|
||||
# Time Tracking Module Safety Flags
|
||||
TIMETRACKING_VTIGER_READ_ONLY: bool = True
|
||||
@ -208,6 +213,15 @@ class Settings(BaseSettings):
|
||||
BACKUP_INCLUDE_LOGS: bool = True # Include logs/ in file backups
|
||||
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
|
||||
UPLOAD_DIR: str = "uploads" # Upload directory path
|
||||
|
||||
# Bug report capture
|
||||
BUG_REPORT_ENABLED: bool = True
|
||||
BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B"
|
||||
BUG_REPORT_MAX_PER_HOUR: int = 12
|
||||
BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1
|
||||
BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1
|
||||
BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024
|
||||
BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024
|
||||
|
||||
# Offsite Backup Settings (SFTP)
|
||||
OFFSITE_ENABLED: bool = False
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
@ -20,7 +21,10 @@ async def customer_detail_page(request: Request, customer_id: int):
|
||||
"""
|
||||
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
|
||||
"request": request,
|
||||
"customer_id": customer_id
|
||||
"customer_id": customer_id,
|
||||
"customer_default_margin_percent": settings.CUSTOMER_DEFAULT_MARGIN_PERCENT,
|
||||
"customer_default_invoice_fee": settings.CUSTOMER_DEFAULT_INVOICE_FEE,
|
||||
"customer_default_hourly_rate": settings.CUSTOMER_DEFAULT_HOURLY_RATE,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -443,6 +443,26 @@
|
||||
<span class="info-label">EAN-nummer</span>
|
||||
<span class="info-value" id="ean">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Standard avance</span>
|
||||
<span class="info-value" id="standardMarginPercent">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Standard timepris</span>
|
||||
<span class="info-value" id="standardHourlyRate">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Særlig fragtpris</span>
|
||||
<span class="info-value" id="specialFreightPrice">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Leverandørservice</span>
|
||||
<span class="info-value" id="supplierServiceEnrolled">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Faktureringsgebyr</span>
|
||||
<span class="info-value" id="invoiceFeeAmount">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Spærret</span>
|
||||
<span class="info-value" id="barred">-</span>
|
||||
@ -1022,6 +1042,43 @@
|
||||
<label for="editCity" class="form-label">By</label>
|
||||
<input type="text" class="form-control" id="editCity">
|
||||
</div>
|
||||
|
||||
<!-- Economic defaults -->
|
||||
<div class="col-12 mt-4">
|
||||
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
||||
<i class="bi bi-currency-exchange me-2"></i>Økonomiske standarder
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editStandardMarginPercent" class="form-label">Standard avance (%)</label>
|
||||
<input type="number" class="form-control" id="editStandardMarginPercent" min="0" max="1000" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editStandardHourlyRate" class="form-label">Standard timepris (DKK)</label>
|
||||
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
|
||||
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
|
||||
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
|
||||
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
|
||||
<label class="form-check-label" for="editSupplierServiceEnrolled">
|
||||
Tilmeldt leverandørservice
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-12 mt-4">
|
||||
@ -1319,6 +1376,9 @@
|
||||
|
||||
<script>
|
||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||
const customerDefaultMarginPercent = Number({{ customer_default_margin_percent | tojson }} || 20);
|
||||
const customerDefaultInvoiceFee = Number({{ customer_default_invoice_fee | tojson }} || 49);
|
||||
const customerDefaultHourlyRate = Number({{ customer_default_hourly_rate | tojson }} || 1200);
|
||||
let customerData = null;
|
||||
let pipelineStages = [];
|
||||
let allTagsCache = [];
|
||||
@ -1674,6 +1734,22 @@ function displayCustomer(customer) {
|
||||
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
||||
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
||||
document.getElementById('ean').textContent = customer.ean || '-';
|
||||
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
|
||||
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
|
||||
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
|
||||
const freight = customer.special_freight_price;
|
||||
|
||||
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
|
||||
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
|
||||
document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
|
||||
? '-'
|
||||
: `${Number(freight).toFixed(2)} DKK`;
|
||||
document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
|
||||
? '<span class="badge bg-success">Tilmeldt</span>'
|
||||
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
|
||||
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
|
||||
? '0,00 DKK (deaktiveret)'
|
||||
: `${Number(invoiceFee).toFixed(2)} DKK`;
|
||||
document.getElementById('barred').innerHTML = customer.barred
|
||||
? '<span class="badge bg-danger">Ja</span>'
|
||||
: '<span class="badge bg-success">Nej</span>';
|
||||
@ -3899,6 +3975,11 @@ function editCustomer() {
|
||||
document.getElementById('editAddress').value = customerData.address || '';
|
||||
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
||||
document.getElementById('editCity').value = customerData.city || '';
|
||||
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
|
||||
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
|
||||
document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
|
||||
document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
|
||||
document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
|
||||
document.getElementById('editIsActive').checked = customerData.is_active !== false;
|
||||
|
||||
// Show modal
|
||||
@ -3907,6 +3988,11 @@ function editCustomer() {
|
||||
}
|
||||
|
||||
async function saveCustomerEdit() {
|
||||
const marginValue = document.getElementById('editStandardMarginPercent').value;
|
||||
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
|
||||
const freightValue = document.getElementById('editSpecialFreightPrice').value;
|
||||
const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
|
||||
|
||||
const updateData = {
|
||||
name: document.getElementById('editName').value,
|
||||
cvr_number: document.getElementById('editCvrNumber').value || null,
|
||||
@ -3920,6 +4006,11 @@ async function saveCustomerEdit() {
|
||||
address: document.getElementById('editAddress').value || null,
|
||||
postal_code: document.getElementById('editPostalCode').value || null,
|
||||
city: document.getElementById('editCity').value || null,
|
||||
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
|
||||
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue),
|
||||
special_freight_price: freightValue === '' ? null : Number(freightValue),
|
||||
supplier_service_enrolled: document.getElementById('editSupplierServiceEnrolled').checked,
|
||||
invoice_fee_amount: invoiceFeeValue === '' ? customerDefaultInvoiceFee : Number(invoiceFeeValue),
|
||||
is_active: document.getElementById('editIsActive').checked
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@ -8,8 +10,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.config import settings
|
||||
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__)
|
||||
|
||||
@ -432,6 +432,259 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
|
||||
return int(order_id)
|
||||
|
||||
|
||||
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
|
||||
customer = execute_query_single(
|
||||
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||
|
||||
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||||
hub_customer_id = customer.get("hub_customer_id")
|
||||
|
||||
hub_customer = None
|
||||
if hub_customer_id:
|
||||
hub_customer = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
standard_hourly_rate,
|
||||
standard_margin_percent,
|
||||
special_freight_price,
|
||||
supplier_service_enrolled,
|
||||
invoice_fee_amount
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
|
||||
invoice_fee_amount = Decimal(
|
||||
str(
|
||||
(hub_customer or {}).get("invoice_fee_amount")
|
||||
if (hub_customer or {}).get("invoice_fee_amount") is not None
|
||||
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
|
||||
)
|
||||
)
|
||||
special_freight_price = (hub_customer or {}).get("special_freight_price")
|
||||
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
|
||||
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
|
||||
standard_margin_percent = Decimal(
|
||||
str(
|
||||
(hub_customer or {}).get("standard_margin_percent")
|
||||
if (hub_customer or {}).get("standard_margin_percent") is not None
|
||||
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
|
||||
)
|
||||
)
|
||||
base_hourly_rate = Decimal(
|
||||
str(
|
||||
(hub_customer or {}).get("standard_hourly_rate")
|
||||
if (hub_customer or {}).get("standard_hourly_rate") is not None
|
||||
else hourly_rate
|
||||
)
|
||||
)
|
||||
|
||||
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||
"rows": [],
|
||||
"case_title": "Time entries",
|
||||
"case_id": None,
|
||||
"sag_id": None,
|
||||
})
|
||||
|
||||
for row in rows:
|
||||
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
|
||||
grouped[group_key]["rows"].append(row)
|
||||
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
|
||||
grouped[group_key]["case_id"] = row.get("case_id")
|
||||
grouped[group_key]["sag_id"] = row.get("sag_id")
|
||||
|
||||
line_payloads: List[Dict[str, Any]] = []
|
||||
|
||||
for _, group in grouped.items():
|
||||
qty = Decimal("0")
|
||||
ids: List[int] = []
|
||||
latest_date = None
|
||||
|
||||
for row in group["rows"]:
|
||||
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
|
||||
ids.append(int(row["id"]))
|
||||
wd = row.get("worked_date")
|
||||
if wd and (latest_date is None or wd > latest_date):
|
||||
latest_date = wd
|
||||
|
||||
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
|
||||
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
|
||||
amount = (qty * unit_price).quantize(Decimal("0.01"))
|
||||
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
|
||||
"source_type": "timequeue",
|
||||
"source_id": ids[0] if ids else None,
|
||||
"description": group["case_title"],
|
||||
"quantity": float(qty),
|
||||
"unit_price": float(unit_price),
|
||||
"discount_percentage": 0,
|
||||
"unit": "timer",
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(amount),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"sag_id": group["sag_id"],
|
||||
"time_entry_ids": ids,
|
||||
"time_date": str(latest_date) if latest_date else None,
|
||||
"meta": {
|
||||
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
|
||||
"standard_margin_percent": float(effective_margin_percent),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if special_freight_amount > 0:
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"freight:{hub_customer_id or customer_id}",
|
||||
"source_type": "freight",
|
||||
"source_id": None,
|
||||
"description": "Særlig fragtpris",
|
||||
"quantity": 1.0,
|
||||
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||
"discount_percentage": 0,
|
||||
"unit": "stk",
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"sag_id": None,
|
||||
"time_entry_ids": [],
|
||||
"time_date": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Fee line is included by default unless customer-specific value is 0.
|
||||
if invoice_fee_amount > 0 and not supplier_service_enrolled:
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
|
||||
"source_type": "invoice_fee",
|
||||
"source_id": None,
|
||||
"description": "Faktureringsgebyr",
|
||||
"quantity": 1.0,
|
||||
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||
"discount_percentage": 0,
|
||||
"unit": "stk",
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"sag_id": None,
|
||||
"time_entry_ids": [],
|
||||
"time_date": None,
|
||||
"meta": {
|
||||
"standard_margin_percent": float(standard_margin_percent),
|
||||
"supplier_service_enrolled": supplier_service_enrolled,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if not line_payloads:
|
||||
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
|
||||
|
||||
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
|
||||
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
|
||||
|
||||
draft = execute_query_single(
|
||||
"""
|
||||
INSERT INTO ordre_drafts (
|
||||
title,
|
||||
customer_id,
|
||||
lines_json,
|
||||
notes,
|
||||
layout_number,
|
||||
created_by_user_id,
|
||||
sync_status,
|
||||
export_status_json,
|
||||
invoice_aggregate_key,
|
||||
updated_at
|
||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
draft_title,
|
||||
int(hub_customer_id) if hub_customer_id else None,
|
||||
json.dumps(line_payloads, ensure_ascii=False),
|
||||
"Genereret fra Economy Time Queue",
|
||||
1,
|
||||
user_id,
|
||||
json.dumps({}, ensure_ascii=False),
|
||||
invoice_aggregate_key,
|
||||
),
|
||||
)
|
||||
if not draft:
|
||||
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
|
||||
|
||||
return int(draft["id"])
|
||||
|
||||
|
||||
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
|
||||
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
|
||||
|
||||
Accepts:
|
||||
- direct tmodule customer id
|
||||
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
|
||||
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
|
||||
"""
|
||||
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
|
||||
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM tmodule_customers
|
||||
WHERE hub_customer_id = %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
if raw_customer_id is not None:
|
||||
try:
|
||||
cid = int(raw_customer_id)
|
||||
except (TypeError, ValueError):
|
||||
cid = None
|
||||
|
||||
if cid and cid > 0:
|
||||
direct = _find_by_tmodule_id(cid)
|
||||
if direct:
|
||||
return direct
|
||||
mapped = _find_by_hub_customer_id(cid)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
if sag_id is not None:
|
||||
try:
|
||||
sid = int(sag_id)
|
||||
except (TypeError, ValueError):
|
||||
sid = None
|
||||
|
||||
if sid and sid > 0:
|
||||
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
|
||||
hub_customer_id = (sag or {}).get("customer_id") if sag else None
|
||||
if hub_customer_id:
|
||||
mapped = _find_by_hub_customer_id(int(hub_customer_id))
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/time-queue/send-to-invoices")
|
||||
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
ids = _ensure_ids(payload.ids)
|
||||
@ -467,18 +720,14 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="No eligible entries found")
|
||||
|
||||
# Ensure selected invoice candidates are approved and invoice-billable.
|
||||
selected_invoice_ids = [
|
||||
int(r["id"])
|
||||
for r in rows
|
||||
if bool(r.get("billable", True))
|
||||
and (r.get("billing_method") or "invoice") == "invoice"
|
||||
]
|
||||
# Local order creation must not depend on e-conomic data/mapping.
|
||||
# Selected entries are converted to local orders regardless of billing method.
|
||||
selected_order_ids = [int(r["id"]) for r in rows]
|
||||
|
||||
if not selected_invoice_ids:
|
||||
raise HTTPException(status_code=400, detail="No selected entries are invoice-billable")
|
||||
if not selected_order_ids:
|
||||
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(
|
||||
f"""
|
||||
UPDATE tmodule_times
|
||||
@ -490,38 +739,67 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
WHERE id IN ({placeholders_invoice})
|
||||
AND status <> 'billed'
|
||||
""",
|
||||
tuple(selected_invoice_ids),
|
||||
tuple(selected_order_ids),
|
||||
)
|
||||
|
||||
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
skipped_missing_customer: List[int] = []
|
||||
for row in rows:
|
||||
if int(row["id"]) in selected_invoice_ids:
|
||||
rows_by_customer[int(row["customer_id"])].append(row)
|
||||
if int(row["id"]) not in selected_order_ids:
|
||||
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():
|
||||
order_id = _create_order_from_selected(cust_id, cust_rows, user_id)
|
||||
export_result = await economic_service.export_order(
|
||||
TModuleEconomicExportRequest(order_id=order_id, force=False),
|
||||
user_id=user_id,
|
||||
)
|
||||
export_results.append(
|
||||
{
|
||||
"customer_id": cust_id,
|
||||
"order_id": order_id,
|
||||
"success": bool(export_result.success),
|
||||
"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,
|
||||
}
|
||||
)
|
||||
try:
|
||||
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
|
||||
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
|
||||
except HTTPException as ex:
|
||||
failed_customers.append(
|
||||
{
|
||||
"customer_id": cust_id,
|
||||
"entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
|
||||
"error": str(ex.detail),
|
||||
}
|
||||
)
|
||||
|
||||
if not created_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 {
|
||||
"success": True,
|
||||
"selected": len(ids),
|
||||
"invoice_candidates": len(selected_invoice_ids),
|
||||
"exports": export_results,
|
||||
"order_candidates": len(selected_order_ids),
|
||||
"created_drafts": created_drafts,
|
||||
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
|
||||
"skipped_missing_customer": skipped_missing_customer,
|
||||
"failed_customers": failed_customers,
|
||||
"orders_url": orders_url,
|
||||
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@ -7,13 +7,13 @@
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<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 class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
|
||||
<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-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>
|
||||
|
||||
@ -447,7 +447,7 @@
|
||||
const ids = selectedIds();
|
||||
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;
|
||||
|
||||
try {
|
||||
@ -455,10 +455,19 @@
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const exports = (result.exports || []).map((x) => {
|
||||
return `customer ${x.customer_id}, order ${x.order_id}, success=${x.success}, dry_run=${x.dry_run}`;
|
||||
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
|
||||
const draftId = x.draft_id || x.order_id;
|
||||
return `customer ${x.customer_id}, draft ${draftId}`;
|
||||
}).join('\n');
|
||||
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 loadEntries();
|
||||
} catch (err) {
|
||||
|
||||
@ -35,6 +35,11 @@ class CustomerUpdate(BaseModel):
|
||||
mobile_phone: Optional[str] = None
|
||||
invoice_email: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
standard_margin_percent: Optional[float] = None
|
||||
standard_hourly_rate: Optional[float] = None
|
||||
special_freight_price: Optional[float] = None
|
||||
supplier_service_enrolled: Optional[bool] = None
|
||||
invoice_fee_amount: Optional[float] = None
|
||||
|
||||
|
||||
class Customer(CustomerBase):
|
||||
|
||||
@ -1181,6 +1181,12 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
- 500: Database error
|
||||
"""
|
||||
try:
|
||||
def _none_if_empty(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
# Check location exists
|
||||
location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
|
||||
location_check = execute_query(location_query, (location_id,))
|
||||
@ -1193,6 +1199,90 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
)
|
||||
|
||||
location_name = location_check[0]['name']
|
||||
|
||||
if not data.existing_contact_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Du skal vælge en eksisterende kontakt"
|
||||
)
|
||||
|
||||
existing_contact_query = """
|
||||
SELECT id, first_name, last_name, email, phone, mobile, title
|
||||
FROM contacts
|
||||
WHERE id = %s
|
||||
"""
|
||||
existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,))
|
||||
|
||||
if not existing_contact_result:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Existing contact with id {data.existing_contact_id} not found"
|
||||
)
|
||||
|
||||
existing_contact = existing_contact_result[0]
|
||||
contact_name = (
|
||||
f"{(existing_contact.get('first_name') or '').strip()} "
|
||||
f"{(existing_contact.get('last_name') or '').strip()}"
|
||||
).strip()
|
||||
contact_email = _none_if_empty(existing_contact.get('email'))
|
||||
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
|
||||
contact_role = _none_if_empty(data.role) or _none_if_empty(existing_contact.get('title'))
|
||||
|
||||
if not contact_name:
|
||||
raise HTTPException(status_code=400, detail="Valgt kontakt mangler navn")
|
||||
|
||||
existing_relation_result = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
FROM locations_contacts
|
||||
WHERE location_id = %s
|
||||
AND related_contact_id = %s
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(location_id, data.existing_contact_id),
|
||||
)
|
||||
|
||||
if existing_relation_result:
|
||||
existing_relation = existing_relation_result[0]
|
||||
|
||||
if data.is_primary and not existing_relation.get("is_primary"):
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE locations_contacts
|
||||
SET is_primary = false
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(location_id,),
|
||||
)
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE locations_contacts
|
||||
SET is_primary = true
|
||||
WHERE id = %s
|
||||
RETURNING id, location_id, related_contact_id, contact_name, contact_email,
|
||||
contact_phone, role, is_primary, created_at
|
||||
""",
|
||||
(existing_relation["id"],),
|
||||
)
|
||||
existing_relation_result = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
FROM locations_contacts
|
||||
WHERE id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(existing_relation["id"],),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ℹ️ Existing contact relation reused for location %s and contact %s",
|
||||
location_id,
|
||||
data.existing_contact_id,
|
||||
)
|
||||
return Contact(**existing_relation_result[0])
|
||||
|
||||
# If is_primary is true, unset primary flag on other contacts
|
||||
if data.is_primary:
|
||||
@ -1202,25 +1292,55 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
"""
|
||||
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_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at, updated_at
|
||||
if has_related_contact_id_column:
|
||||
insert_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
location_id,
|
||||
data.existing_contact_id,
|
||||
contact_name,
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_role,
|
||||
data.is_primary,
|
||||
)
|
||||
else:
|
||||
insert_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
location_id,
|
||||
contact_name,
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_role,
|
||||
data.is_primary,
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
location_id,
|
||||
data.contact_name,
|
||||
data.contact_email,
|
||||
data.contact_phone,
|
||||
data.role,
|
||||
data.is_primary
|
||||
)
|
||||
|
||||
result = execute_query(insert_query, params)
|
||||
|
||||
@ -1230,7 +1350,16 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
|
||||
contact = Contact(**result[0])
|
||||
|
||||
logger.info(f"✅ Contact added: {data.contact_name} at {location_name} (Location ID: {location_id})")
|
||||
if data.existing_contact_id:
|
||||
logger.info(
|
||||
"✅ Contact added from existing contact %s: %s at %s (Location ID: %s)",
|
||||
data.existing_contact_id,
|
||||
contact_name,
|
||||
location_name,
|
||||
location_id,
|
||||
)
|
||||
else:
|
||||
logger.info(f"✅ Contact added: {contact_name} at {location_name} (Location ID: {location_id})")
|
||||
return contact
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@ -412,8 +412,85 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
(id,)
|
||||
)
|
||||
|
||||
contacts = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
FROM locations_contacts
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY is_primary DESC, contact_name ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
operating_hours = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, day_of_week,
|
||||
CASE day_of_week
|
||||
WHEN 0 THEN 'Mandag'
|
||||
WHEN 1 THEN 'Tirsdag'
|
||||
WHEN 2 THEN 'Onsdag'
|
||||
WHEN 3 THEN 'Torsdag'
|
||||
WHEN 4 THEN 'Fredag'
|
||||
WHEN 5 THEN 'Lørdag'
|
||||
WHEN 6 THEN 'Søndag'
|
||||
END AS day_name,
|
||||
open_time, close_time, is_open, notes
|
||||
FROM locations_hours
|
||||
WHERE location_id = %s
|
||||
ORDER BY day_of_week ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
services = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, service_name, is_available, created_at
|
||||
FROM locations_services
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY service_name ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
capacity = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, capacity_type, total_capacity, used_capacity, last_updated
|
||||
FROM locations_capacity
|
||||
WHERE location_id = %s
|
||||
ORDER BY capacity_type ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
hardware = execute_query(
|
||||
"""
|
||||
SELECT id, asset_type, brand, model, serial_number, status
|
||||
FROM hardware_assets
|
||||
WHERE current_location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY brand ASC, model ASC, serial_number ASC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
audit_log = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, event_type, user_id, changes, created_at
|
||||
FROM locations_audit_log
|
||||
WHERE location_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(id,)
|
||||
)
|
||||
|
||||
location["hierarchy"] = hierarchy
|
||||
location["children"] = children
|
||||
location["contacts"] = contacts or []
|
||||
location["operating_hours"] = operating_hours or []
|
||||
location["services"] = services or []
|
||||
location["capacity"] = capacity or []
|
||||
location["hardware"] = hardware or []
|
||||
location["audit_log"] = audit_log or []
|
||||
|
||||
# Query customers
|
||||
customers = execute_query("""
|
||||
|
||||
@ -117,8 +117,18 @@ class ContactBase(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""Request model for creating contact"""
|
||||
class ContactCreate(BaseModel):
|
||||
"""Request model for linking an existing global contact to a location"""
|
||||
existing_contact_id: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="ID of existing global contact"
|
||||
)
|
||||
role: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Optional location-specific role override"
|
||||
)
|
||||
is_primary: bool = Field(False, description="Set as primary contact for location")
|
||||
|
||||
|
||||
@ -135,6 +145,7 @@ class Contact(ContactBase):
|
||||
"""Full contact response model"""
|
||||
id: int
|
||||
location_id: int
|
||||
related_contact_id: Optional[int] = None
|
||||
is_primary: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@ -2,8 +2,210 @@
|
||||
|
||||
{% block title %}{{ location.name }} - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.locations-detail-page {
|
||||
--loc-accent: var(--accent, #0f4c75);
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0,0,0,0.06),
|
||||
0 4px 6px -1px rgba(0,0,0,0.05),
|
||||
0 16px 32px -8px rgba(15,76,117,0.10);
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(15,76,117,0.05) 0%, rgba(15,76,117,0.01) 100%);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 16px 16px 0 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--loc-accent);
|
||||
background: color-mix(in srgb, var(--loc-accent) 10%, transparent);
|
||||
border: 1.5px solid color-mix(in srgb, var(--loc-accent) 30%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.2em 0.65em;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--loc-accent);
|
||||
background: color-mix(in srgb, var(--loc-accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--loc-accent) 25%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.32em 0.8em;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 999px;
|
||||
padding: 0.3em 0.85em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-chip.open {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-chip.closed {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem 1rem 0.95rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-meta-cell {
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--loc-accent) 4%, var(--bg-card, #fff));
|
||||
padding: 0.58rem 0.7rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .hero-meta-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.locations-detail-page .hero-meta-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs {
|
||||
border-bottom: none;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs .nav-link {
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card, #fff);
|
||||
font-weight: 600;
|
||||
padding: 0.44rem 0.82rem;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs .nav-link:hover,
|
||||
.locations-detail-page #locationTabs .nav-link:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--loc-accent) 45%, transparent);
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-detail-page #locationTabs .nav-link.active {
|
||||
background: color-mix(in srgb, var(--loc-accent) 12%, var(--bg-card, #fff));
|
||||
border-color: color-mix(in srgb, var(--loc-accent) 40%, transparent);
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-detail-page .location-tab-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
padding: 0 0.34rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
background: color-mix(in srgb, var(--loc-accent) 14%, transparent);
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-detail-page .card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.08) !important;
|
||||
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
.locations-detail-page .list-group-item {
|
||||
border-radius: 0.8rem !important;
|
||||
margin-bottom: 0.45rem;
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
}
|
||||
|
||||
.locations-detail-page .timeline-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.locations-detail-page .timeline-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 20px;
|
||||
bottom: -14px;
|
||||
width: 1px;
|
||||
background: rgba(15, 76, 117, 0.2);
|
||||
}
|
||||
|
||||
.locations-detail-page .timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.locations-detail-page {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-detail-page .case-hero-meta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="container-fluid px-4 py-4 locations-detail-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
@ -16,24 +218,10 @@
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h1 class="h2 fw-700 mb-2">{{ location.name }}</h1>
|
||||
{% if location.hierarchy %}
|
||||
<nav aria-label="breadcrumb" class="mb-2">
|
||||
<ol class="breadcrumb mb-0">
|
||||
{% for node in location.hierarchy %}
|
||||
<li class="breadcrumb-item">
|
||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
|
||||
{{ node.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ location.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<div class="case-hero">
|
||||
<div class="case-hero-identity">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="case-id-chip">Lokation #{{ location.id }}</span>
|
||||
{% set type_label = {
|
||||
'kompleks': 'Kompleks',
|
||||
'bygning': 'Bygning',
|
||||
@ -55,42 +243,63 @@
|
||||
'customer_site': '#9b59b6',
|
||||
'vehicle': '#8e44ad'
|
||||
}.get(location.location_type, '#6c757d') %}
|
||||
|
||||
<span class="badge" style="background-color: {{ type_color }}; color: white;">
|
||||
|
||||
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
||||
{{ type_label }}
|
||||
</span>
|
||||
{% if location.parent_location_id and location.parent_location_name %}
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-diagram-3 me-1"></i>
|
||||
<a href="/app/locations/{{ location.parent_location_id }}" class="text-decoration-none">
|
||||
{{ location.parent_location_name }}
|
||||
</a>
|
||||
{% if location.is_active %}
|
||||
<span class="case-status-chip open">
|
||||
<span class="case-status-dot"></span>Aktiv
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="case-status-chip closed">
|
||||
<span class="case-status-dot"></span>Inaktiv
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if location.is_active %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-pencil me-2"></i>Rediger
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
<i class="bi bi-trash me-2"></i>Slet
|
||||
</button>
|
||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-pencil me-2"></i>Rediger
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
<i class="bi bi-trash me-2"></i>Slet
|
||||
</button>
|
||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||||
</a>
|
||||
|
||||
<div class="case-hero-meta">
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Navn</div>
|
||||
<div class="hero-meta-value">{{ location.name }}</div>
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Overordnet</div>
|
||||
{% if location.parent_location_id and location.parent_location_name %}
|
||||
<a href="/app/locations/{{ location.parent_location_id }}" class="hero-meta-value text-decoration-none">
|
||||
{{ location.parent_location_name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="hero-meta-value text-muted">Ingen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Kontakter</div>
|
||||
<div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div>
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Tjenester</div>
|
||||
<div class="hero-meta-value">{{ location.services|length if location.services else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||
<ul class="nav nav-tabs mb-4" id="locationTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="infoTab" data-bs-toggle="tab" data-bs-target="#infoContent" type="button" role="tab" aria-controls="infoContent" aria-selected="true">
|
||||
<i class="bi bi-info-circle me-2"></i>Oplysninger
|
||||
@ -99,6 +308,7 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="contactsTab" data-bs-toggle="tab" data-bs-target="#contactsContent" type="button" role="tab" aria-controls="contactsContent" aria-selected="false">
|
||||
<i class="bi bi-people me-2"></i>Kontakter
|
||||
<span class="location-tab-count-badge ms-1">{{ location.contacts|length if location.contacts else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -109,11 +319,13 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="servicesTab" data-bs-toggle="tab" data-bs-target="#servicesContent" type="button" role="tab" aria-controls="servicesContent" aria-selected="false">
|
||||
<i class="bi bi-tools me-2"></i>Tjenester
|
||||
<span class="location-tab-count-badge ms-1">{{ location.services|length if location.services else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="capacityTab" data-bs-toggle="tab" data-bs-target="#capacityContent" type="button" role="tab" aria-controls="capacityContent" aria-selected="false">
|
||||
<i class="bi bi-graph-up me-2"></i>Kapacitet
|
||||
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -124,6 +336,7 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="hardwareTab" data-bs-toggle="tab" data-bs-target="#hardwareContent" type="button" role="tab" aria-controls="hardwareContent" aria-selected="false">
|
||||
<i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
|
||||
<span class="location-tab-count-badge ms-1">{{ location.hardware|length if location.hardware else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -301,7 +514,13 @@
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="fw-600 mb-1">{{ contact.contact_name }}</h6>
|
||||
<h6 class="fw-600 mb-1">
|
||||
{% if contact.related_contact_id %}
|
||||
<a href="/contacts/{{ contact.related_contact_id }}" class="text-decoration-none">{{ contact.contact_name }}</a>
|
||||
{% else %}
|
||||
{{ contact.contact_name }}
|
||||
{% endif %}
|
||||
</h6>
|
||||
<p class="small text-muted mb-2">
|
||||
{% if contact.role %}{{ contact.role }}{% endif %}
|
||||
{% if contact.is_primary %}<span class="badge bg-info ms-2">Primær</span>{% endif %}
|
||||
@ -663,16 +882,21 @@
|
||||
<form id="addContactForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="contactName" class="form-label">Navn *</label>
|
||||
<input type="text" class="form-control" id="contactName" required>
|
||||
<label for="existingContactSearch" class="form-label">Søg eksisterende kontakt</label>
|
||||
<input type="text" class="form-control" id="existingContactSearch" placeholder="Skriv navn, email eller telefon...">
|
||||
<div class="form-text">Vælg en eksisterende kontakt for at udfylde felterne automatisk.</div>
|
||||
<div id="existingContactResults" class="list-group mt-2 d-none" style="max-height: 220px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="contactEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="contactEmail">
|
||||
<div id="selectedExistingContact" class="alert alert-info py-2 px-3 d-none" role="alert">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span id="selectedExistingContactText" class="small mb-0"></span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0" id="clearExistingContactBtn">Fjern</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="existingContactId" value="">
|
||||
<div class="mb-3">
|
||||
<label for="contactPhone" class="form-label">Telefon</label>
|
||||
<input type="tel" class="form-control" id="contactPhone">
|
||||
<label class="form-label">Kontaktoplysninger</label>
|
||||
<div class="form-control-plaintext small text-muted" id="selectedContactMeta">Vælg en kontakt for at se email og telefon.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="contactRole" class="form-label">Rolle</label>
|
||||
@ -781,6 +1005,129 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
const locationId = '{{ location.id }}';
|
||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||
const existingContactIdInput = document.getElementById('existingContactId');
|
||||
const selectedExistingContactAlert = document.getElementById('selectedExistingContact');
|
||||
const selectedExistingContactText = document.getElementById('selectedExistingContactText');
|
||||
const selectedContactMeta = document.getElementById('selectedContactMeta');
|
||||
const clearExistingContactBtn = document.getElementById('clearExistingContactBtn');
|
||||
const contactRoleInput = document.getElementById('contactRole');
|
||||
const addContactModalElement = document.getElementById('addContactModal');
|
||||
const addContactForm = document.getElementById('addContactForm');
|
||||
const addContactSubmitBtn = addContactForm?.querySelector('button[type="submit"]');
|
||||
let existingContactResults = [];
|
||||
let contactSearchDebounceTimer = null;
|
||||
let isSavingContact = false;
|
||||
|
||||
function clearExistingContactSelection() {
|
||||
existingContactIdInput.value = '';
|
||||
selectedExistingContactText.textContent = '';
|
||||
selectedExistingContactAlert.classList.add('d-none');
|
||||
selectedContactMeta.textContent = 'Vælg en kontakt for at se email og telefon.';
|
||||
}
|
||||
|
||||
function hideExistingContactResults() {
|
||||
existingContactResults = [];
|
||||
existingContactResultsContainer.innerHTML = '';
|
||||
existingContactResultsContainer.classList.add('d-none');
|
||||
}
|
||||
|
||||
function buildFullName(contact) {
|
||||
const firstName = (contact.first_name || '').trim();
|
||||
const lastName = (contact.last_name || '').trim();
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
|
||||
function selectExistingContact(contact) {
|
||||
const fullName = buildFullName(contact);
|
||||
const email = contact.email || '';
|
||||
const phone = contact.mobile || contact.phone || '';
|
||||
|
||||
existingContactIdInput.value = String(contact.id || '');
|
||||
selectedExistingContactText.textContent = `Valgt: ${fullName || 'Kontakt'} (ID: ${contact.id})`;
|
||||
selectedExistingContactAlert.classList.remove('d-none');
|
||||
selectedContactMeta.textContent = `${email || 'Ingen email'} • ${phone || 'Ingen telefon'}`;
|
||||
|
||||
hideExistingContactResults();
|
||||
existingContactSearchInput.value = fullName;
|
||||
}
|
||||
|
||||
function renderExistingContactResults(results) {
|
||||
if (!results || !results.length) {
|
||||
hideExistingContactResults();
|
||||
return;
|
||||
}
|
||||
|
||||
existingContactResults = results;
|
||||
existingContactResultsContainer.innerHTML = results.map((contact, index) => {
|
||||
const fullName = buildFullName(contact) || `Kontakt #${contact.id}`;
|
||||
const secondaryInfo = [contact.email, contact.mobile || contact.phone, contact.user_company]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action existing-contact-result" data-index="${index}">
|
||||
<div class="fw-500">${fullName}</div>
|
||||
<div class="small text-muted">${secondaryInfo || 'Ingen ekstra info'}</div>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
existingContactResultsContainer.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function searchExistingContacts(term) {
|
||||
if (!term || term.trim().length < 2) {
|
||||
hideExistingContactResults();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(term.trim())}`);
|
||||
if (!response.ok) {
|
||||
hideExistingContactResults();
|
||||
return;
|
||||
}
|
||||
|
||||
const contacts = await response.json();
|
||||
renderExistingContactResults(contacts || []);
|
||||
} catch (error) {
|
||||
console.error('Error searching contacts:', error);
|
||||
hideExistingContactResults();
|
||||
}
|
||||
}
|
||||
|
||||
existingContactSearchInput.addEventListener('input', function(e) {
|
||||
const term = e.target.value;
|
||||
if (contactSearchDebounceTimer) {
|
||||
clearTimeout(contactSearchDebounceTimer);
|
||||
}
|
||||
contactSearchDebounceTimer = setTimeout(() => {
|
||||
searchExistingContacts(term);
|
||||
}, 220);
|
||||
});
|
||||
|
||||
existingContactResultsContainer.addEventListener('click', function(e) {
|
||||
const button = e.target.closest('.existing-contact-result');
|
||||
if (!button) return;
|
||||
|
||||
const index = Number(button.dataset.index);
|
||||
const selectedContact = existingContactResults[index];
|
||||
if (selectedContact) {
|
||||
selectExistingContact(selectedContact);
|
||||
}
|
||||
});
|
||||
|
||||
clearExistingContactBtn.addEventListener('click', function() {
|
||||
clearExistingContactSelection();
|
||||
existingContactSearchInput.value = '';
|
||||
hideExistingContactResults();
|
||||
});
|
||||
|
||||
addContactModalElement.addEventListener('hidden.bs.modal', function() {
|
||||
hideExistingContactResults();
|
||||
clearExistingContactSelection();
|
||||
existingContactSearchInput.value = '';
|
||||
});
|
||||
|
||||
// Delete location
|
||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||
@ -803,14 +1150,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
// Add contact form
|
||||
document.getElementById('addContactForm').addEventListener('submit', function(e) {
|
||||
addContactForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (isSavingContact) {
|
||||
return;
|
||||
}
|
||||
if (!existingContactIdInput.value) {
|
||||
alert('Vælg en eksisterende kontakt fra søgningen før du gemmer.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingContact = true;
|
||||
if (addContactSubmitBtn) {
|
||||
addContactSubmitBtn.disabled = true;
|
||||
addContactSubmitBtn.textContent = 'Gemmer...';
|
||||
}
|
||||
|
||||
const contactData = {
|
||||
location_id: locationId,
|
||||
contact_name: document.getElementById('contactName').value,
|
||||
contact_email: document.getElementById('contactEmail').value,
|
||||
contact_phone: document.getElementById('contactPhone').value,
|
||||
role: document.getElementById('contactRole').value,
|
||||
role: contactRoleInput.value,
|
||||
existing_contact_id: existingContactIdInput.value ? parseInt(existingContactIdInput.value, 10) : null,
|
||||
is_primary: document.getElementById('isPrimaryContact').checked
|
||||
};
|
||||
fetch(`/api/v1/locations/${locationId}/contacts`, {
|
||||
@ -818,13 +1177,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(contactData)
|
||||
})
|
||||
.then(response => {
|
||||
.then(async response => {
|
||||
if (response.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
||||
setTimeout(() => location.reload(), 300);
|
||||
return;
|
||||
}
|
||||
|
||||
let detail = 'Fejl ved gem af kontaktlink';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && payload.detail) {
|
||||
detail = String(payload.detail);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors and keep default message
|
||||
}
|
||||
alert(detail);
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Netværksfejl ved gem af kontaktlink');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingContact = false;
|
||||
if (addContactSubmitBtn) {
|
||||
addContactSubmitBtn.disabled = false;
|
||||
addContactSubmitBtn.textContent = 'Tilføj';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add service form
|
||||
|
||||
@ -2,8 +2,91 @@
|
||||
|
||||
{% block title %}Lokaliteter - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.locations-list-page {
|
||||
--loc-accent: #0f4c75;
|
||||
--loc-accent-soft: rgba(15, 76, 117, 0.08);
|
||||
--loc-border: rgba(15, 76, 117, 0.16);
|
||||
}
|
||||
|
||||
.locations-list-page .locations-hero {
|
||||
border: 1px solid var(--loc-border);
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .locations-hero {
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
|
||||
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
background: var(--loc-accent-soft);
|
||||
border: 1px solid var(--loc-border);
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile .stat-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--loc-accent);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
|
||||
color: #8fd0ff;
|
||||
}
|
||||
|
||||
.locations-list-page .table thead th {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row {
|
||||
transition: background-color 0.18s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row:hover {
|
||||
background-color: rgba(52, 152, 219, 0.08);
|
||||
}
|
||||
|
||||
.locations-list-page .toggle-row {
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-list-page .shortcut-hint {
|
||||
border: 1px dashed var(--loc-border);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.locations-list-page {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="container-fluid px-4 py-4 locations-list-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
@ -15,8 +98,41 @@
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h2 fw-700 mb-2">Lokaliteter</h1>
|
||||
<p class="text-muted small">Oversigt over alle lokationer og faciliteter</p>
|
||||
<div class="locations-hero p-3 p-lg-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
||||
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
|
||||
</div>
|
||||
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Total</div>
|
||||
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Aktive</div>
|
||||
<div class="stat-value" id="statActive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Inaktive</div>
|
||||
<div class="stat-value" id="statInactive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Synlige nu</div>
|
||||
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -294,12 +410,32 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('locationSearch');
|
||||
const visibleCount = document.getElementById('visibleCount');
|
||||
const statTotal = document.getElementById('statTotal');
|
||||
const statActive = document.getElementById('statActive');
|
||||
const statInactive = document.getElementById('statInactive');
|
||||
const statVisible = document.getElementById('statVisible');
|
||||
const rows = Array.from(document.querySelectorAll('.location-row'));
|
||||
|
||||
const rowById = new Map();
|
||||
const parentById = new Map();
|
||||
const childrenById = new Map();
|
||||
|
||||
function updateSummaryStats() {
|
||||
const total = rows.length;
|
||||
const active = rows.filter(row => {
|
||||
const statusText = row.querySelector('td:nth-child(5)')?.innerText?.toLowerCase() || '';
|
||||
return statusText.includes('aktiv') && !statusText.includes('inaktiv');
|
||||
}).length;
|
||||
const inactive = Math.max(total - active, 0);
|
||||
const visible = rows.filter(row => !row.classList.contains('d-none')).length;
|
||||
|
||||
if (statTotal) statTotal.textContent = String(total);
|
||||
if (statActive) statActive.textContent = String(active);
|
||||
if (statInactive) statInactive.textContent = String(inactive);
|
||||
if (statVisible) statVisible.textContent = String(visible);
|
||||
if (visibleCount) visibleCount.textContent = String(visible);
|
||||
}
|
||||
|
||||
rows.forEach(row => {
|
||||
const id = row.getAttribute('data-location-id');
|
||||
const parentId = row.getAttribute('data-parent-id') || null;
|
||||
@ -374,6 +510,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
collapseAll();
|
||||
updateSummaryStats();
|
||||
|
||||
function toggleNode(targetId) {
|
||||
const row = rowById.get(targetId);
|
||||
@ -442,6 +579,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (visibleCount) {
|
||||
visibleCount.textContent = String(visible);
|
||||
}
|
||||
if (statVisible) {
|
||||
statVisible.textContent = String(visible);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
@ -450,6 +590,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applySearchFilter, 150);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === '/' && document.activeElement !== searchInput) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select all checkbox
|
||||
|
||||
@ -67,7 +67,10 @@ class OrdreEconomicExportService:
|
||||
if not customer.get("economic_customer_number"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Kunden mangler e-conomic kundenummer i Customers modulet",
|
||||
detail=(
|
||||
f"Kan ikke overfoere ordre til e-conomic: Customer '{customer.get('name')}' "
|
||||
"mangler e-conomic kundenummer. Lokal ordre er bevaret."
|
||||
),
|
||||
)
|
||||
|
||||
selected_lines = [line for line in lines if bool(line.get("selected", True))]
|
||||
|
||||
@ -18,7 +18,7 @@ ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
||||
class OrdreLineInput(BaseModel):
|
||||
line_key: str
|
||||
source_type: str
|
||||
source_id: int
|
||||
source_id: Optional[int] = None
|
||||
description: str
|
||||
quantity: float = Field(gt=0)
|
||||
unit_price: float = Field(ge=0)
|
||||
@ -45,6 +45,10 @@ class OrdreDraftUpsertRequest(BaseModel):
|
||||
layout_number: Optional[int] = None
|
||||
|
||||
|
||||
class OrdreDraftConsolidateRequest(BaseModel):
|
||||
draft_ids: List[int] = Field(..., min_length=2)
|
||||
|
||||
|
||||
def _safe_json_field(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
@ -572,3 +576,114 @@ async def delete_ordre_draft(draft_id: int, http_request: Request):
|
||||
except Exception as e:
|
||||
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to delete ordre draft")
|
||||
|
||||
|
||||
@router.post("/ordre/drafts/consolidate")
|
||||
async def consolidate_ordre_drafts(payload: OrdreDraftConsolidateRequest, http_request: Request):
|
||||
"""Consolidate two or more drafts for the same customer into one draft."""
|
||||
try:
|
||||
draft_ids = sorted(set(int(x) for x in payload.draft_ids if int(x) > 0))
|
||||
if len(draft_ids) < 2:
|
||||
raise HTTPException(status_code=400, detail="Select at least two drafts to consolidate")
|
||||
|
||||
placeholders = ",".join(["%s"] * len(draft_ids))
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM ordre_drafts
|
||||
WHERE id IN ({placeholders})
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
tuple(draft_ids),
|
||||
) or []
|
||||
|
||||
if len(rows) != len(draft_ids):
|
||||
raise HTTPException(status_code=404, detail="One or more drafts were not found")
|
||||
|
||||
customer_ids = {row.get("customer_id") for row in rows}
|
||||
if len(customer_ids) != 1:
|
||||
raise HTTPException(status_code=400, detail="Drafts must belong to the same customer")
|
||||
|
||||
customer_id = next(iter(customer_ids))
|
||||
if customer_id is None:
|
||||
raise HTTPException(status_code=400, detail="Drafts without customer_id cannot be consolidated")
|
||||
|
||||
blocked_statuses = {"exported", "posted", "paid"}
|
||||
for row in rows:
|
||||
sync_status = str(row.get("sync_status") or "pending").strip().lower()
|
||||
if sync_status in blocked_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Draft #{row.get('id')} has status '{sync_status}' and cannot be consolidated",
|
||||
)
|
||||
|
||||
primary = rows[0]
|
||||
secondary_ids = [int(row["id"]) for row in rows[1:]]
|
||||
|
||||
merged_lines: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
lines = _safe_json_field(row.get("lines_json")) or []
|
||||
if isinstance(lines, list):
|
||||
merged_lines.extend(lines)
|
||||
|
||||
notes_parts = [str(row.get("notes")).strip() for row in rows if row.get("notes")]
|
||||
merged_notes = "\n\n".join(part for part in notes_parts if part)
|
||||
|
||||
aggregate_key = primary.get("invoice_aggregate_key") or f"consolidated-customer-{customer_id}"
|
||||
keep_id = int(primary["id"])
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE ordre_drafts
|
||||
SET lines_json = %s::jsonb,
|
||||
notes = %s,
|
||||
invoice_aggregate_key = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
json.dumps(merged_lines, ensure_ascii=False),
|
||||
merged_notes or None,
|
||||
aggregate_key,
|
||||
keep_id,
|
||||
),
|
||||
)
|
||||
|
||||
if secondary_ids:
|
||||
delete_placeholders = ",".join(["%s"] * len(secondary_ids))
|
||||
execute_query(
|
||||
f"DELETE FROM ordre_drafts WHERE id IN ({delete_placeholders})",
|
||||
tuple(secondary_ids),
|
||||
)
|
||||
|
||||
updated = execute_query_single("SELECT * FROM ordre_drafts WHERE id = %s", (keep_id,))
|
||||
|
||||
_log_sync_event(
|
||||
keep_id,
|
||||
"drafts_consolidated",
|
||||
primary.get("sync_status"),
|
||||
primary.get("sync_status"),
|
||||
{
|
||||
"merged_from": draft_ids,
|
||||
"kept_id": keep_id,
|
||||
"customer_id": customer_id,
|
||||
"line_count": len(merged_lines),
|
||||
},
|
||||
_get_user_id_from_request(http_request),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"kept_draft_id": keep_id,
|
||||
"merged_draft_ids": draft_ids,
|
||||
"removed_draft_ids": secondary_ids,
|
||||
"line_count": len(merged_lines),
|
||||
"draft": updated,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error consolidating ordre drafts: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to consolidate ordre drafts")
|
||||
|
||||
@ -199,6 +199,15 @@
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sourceBadge(type) {
|
||||
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
|
||||
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
|
||||
@ -335,9 +344,20 @@
|
||||
const index = line.originalIndex;
|
||||
const isManual = line.source_type === 'manual';
|
||||
const descriptionField = isManual
|
||||
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
||||
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
onchange="ordreLines[${index}].description = this.value;">`
|
||||
: (line.description || '-');
|
||||
: escapeHtml(line.description || '-');
|
||||
|
||||
const manualActions = isManual
|
||||
? `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="resolveManualLineProductByCode(${index})" title="Søg produkt via strengkode i APIGateway">
|
||||
<i class="bi bi-upc-scan"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
`
|
||||
: '-';
|
||||
|
||||
html += `
|
||||
<tr class="order-lines-container" data-order="order-${groupIndex}">
|
||||
@ -361,9 +381,7 @@
|
||||
</td>
|
||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||
<td>${renderExportStatusBadge(line)}</td>
|
||||
<td>
|
||||
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
||||
</td>
|
||||
<td>${manualActions}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
@ -415,6 +433,62 @@
|
||||
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||
}
|
||||
|
||||
async function resolveManualLineProductByCode(index) {
|
||||
const line = ordreLines[index];
|
||||
if (!line) return;
|
||||
|
||||
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
|
||||
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
|
||||
if (!code || !code.trim()) return;
|
||||
|
||||
try {
|
||||
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
|
||||
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
|
||||
if (customerId) {
|
||||
params.append('customer_id', String(customerId));
|
||||
}
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'APIGateway søgning fejlede');
|
||||
}
|
||||
|
||||
if (!data.found || !data.product) {
|
||||
alert('Ingen vare fundet på den strengkode');
|
||||
return;
|
||||
}
|
||||
|
||||
const product = data.product;
|
||||
line.product_id = product.id || line.product_id || null;
|
||||
line.description = product.name || product.product_name || line.description || '';
|
||||
if (!Number(line.unit_price || 0) && product.sales_price != null) {
|
||||
line.unit_price = Number(product.sales_price || 0);
|
||||
}
|
||||
if (product.ean) {
|
||||
line.ean = product.ean;
|
||||
}
|
||||
line.product_code = code.trim();
|
||||
updateLineAmount(index);
|
||||
|
||||
if (data.created) {
|
||||
if (data.applied_customer_margin_percent != null) {
|
||||
alert(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`);
|
||||
} else {
|
||||
alert('Produkt fundet i APIGateway og oprettet lokalt på linjen');
|
||||
}
|
||||
} else if (data.source === 'local') {
|
||||
alert('Produkt fundet lokalt og sat på linjen');
|
||||
} else {
|
||||
alert('Produkt fundet via APIGateway og sat på linjen');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message || 'Søgning fejlede');
|
||||
}
|
||||
}
|
||||
|
||||
function selectCustomer(customer) {
|
||||
document.getElementById('customerId').value = customer.id;
|
||||
document.getElementById('customerSearch').value = customer.name || '';
|
||||
|
||||
@ -377,11 +377,10 @@
|
||||
}
|
||||
|
||||
tbody.innerHTML = orderLines.map((line, index) => {
|
||||
const isManual = line.source_type === 'manual';
|
||||
const descriptionField = isManual
|
||||
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
||||
onchange="orderLines[${index}].description = this.value;">`
|
||||
: (line.description || '-');
|
||||
const isExportedLine = line.export_status === 'exported';
|
||||
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].description = this.value;">`;
|
||||
|
||||
const exportStatus = line.export_status || '-';
|
||||
const statusBadge = exportStatus === 'exported'
|
||||
@ -390,28 +389,40 @@
|
||||
? '<span class="badge bg-warning text-dark">Dry-run</span>'
|
||||
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||
|
||||
const lineActions = isExportedLine
|
||||
? '-'
|
||||
: `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="resolveDetailLineProductByCode(${index})" title="Søg produkt via strengkode i APIGateway">
|
||||
<i class="bi bi-upc-scan"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${sourceBadge(line.source_type)}</td>
|
||||
<td>${descriptionField}</td>
|
||||
<td style="min-width:100px;">
|
||||
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:120px;">
|
||||
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:110px;">
|
||||
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||
<td>${line.unit || 'stk'}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
||||
</td>
|
||||
<td>${lineActions}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@ -461,6 +472,61 @@
|
||||
renderLines();
|
||||
}
|
||||
|
||||
async function resolveDetailLineProductByCode(index) {
|
||||
const line = orderLines[index];
|
||||
if (!line) return;
|
||||
|
||||
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
|
||||
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
|
||||
if (!code || !code.trim()) return;
|
||||
|
||||
try {
|
||||
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
|
||||
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
|
||||
if (customerId) {
|
||||
params.append('customer_id', String(customerId));
|
||||
}
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'APIGateway søgning fejlede');
|
||||
}
|
||||
if (!data.found || !data.product) {
|
||||
showToast('Ingen vare fundet på strengkoden', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const product = data.product;
|
||||
line.product_id = product.id || line.product_id || null;
|
||||
line.description = product.name || product.product_name || line.description || '';
|
||||
if (!Number(line.unit_price || 0) && product.sales_price != null) {
|
||||
line.unit_price = Number(product.sales_price || 0);
|
||||
}
|
||||
if (product.ean) {
|
||||
line.ean = product.ean;
|
||||
}
|
||||
line.product_code = code.trim();
|
||||
|
||||
updateLineAmount(index);
|
||||
if (data.created) {
|
||||
if (data.applied_customer_margin_percent != null) {
|
||||
showToast(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`, 'success');
|
||||
} else {
|
||||
showToast('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
|
||||
}
|
||||
} else if (data.source === 'local') {
|
||||
showToast('Produkt fundet lokalt og tilknyttet linjen', 'success');
|
||||
} else {
|
||||
showToast('Produkt fundet via APIGateway og tilknyttet linjen', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Søgning fejlede', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderLine(line) {
|
||||
// Handle e-conomic format (product.description, unitNetPrice, etc.)
|
||||
if (line.product && line.product.description && !line.description) {
|
||||
|
||||
@ -82,6 +82,7 @@
|
||||
<option value="paid">paid</option>
|
||||
</select>
|
||||
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
|
||||
<button class="btn btn-outline-secondary" onclick="consolidateSelectedByCustomer()"><i class="bi bi-collection me-1"></i>Konsolider valgte</button>
|
||||
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
|
||||
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
||||
</div>
|
||||
@ -400,6 +401,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function consolidateSelectedByCustomer() {
|
||||
const ids = Array.from(selectedOrderIds).map(Number).filter(Boolean);
|
||||
if (ids.length < 2) {
|
||||
showToast('Vælg mindst to ordrer for at konsolidere', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOrders = orders.filter(order => ids.includes(order.id));
|
||||
const groups = {};
|
||||
selectedOrders.forEach(order => {
|
||||
const key = order.customer_id || 'none';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(order.id);
|
||||
});
|
||||
|
||||
const eligibleGroups = Object.entries(groups)
|
||||
.filter(([key, groupIds]) => key !== 'none' && groupIds.length >= 2);
|
||||
|
||||
if (!eligibleGroups.length) {
|
||||
showToast('Ingen valgte ordrer kan konsolideres (kræver samme kunde)', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDrafts = eligibleGroups.reduce((sum, [, groupIds]) => sum + groupIds.length, 0);
|
||||
if (!confirm(`Konsolider ${totalDrafts} valgte ordrer fordelt på ${eligibleGroups.length} kunde-grupper?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
|
||||
for (const [customerId, groupIds] of eligibleGroups) {
|
||||
try {
|
||||
const res = await fetch('/api/v1/ordre/drafts/consolidate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ draft_ids: groupIds }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Konsolidering fejlede');
|
||||
results.push({ customerId, kept: data.kept_draft_id, merged: groupIds.length });
|
||||
} catch (error) {
|
||||
failures.push({ customerId, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
await loadOrders();
|
||||
|
||||
if (results.length) {
|
||||
showToast(`${results.length} kunde-grupper konsolideret`, 'success');
|
||||
}
|
||||
if (failures.length) {
|
||||
const msg = failures.map(f => `Kunde ${f.customerId}: ${f.error}`).join(' | ');
|
||||
showToast(msg, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickSyncStatus(orderId) {
|
||||
const select = document.getElementById(`syncStatus-${orderId}`);
|
||||
const syncStatus = (select?.value || '').trim().toLowerCase();
|
||||
|
||||
@ -4766,11 +4766,35 @@
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
|
||||
} else {
|
||||
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;"
|
||||
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
|
||||
<strong>${c.first_name} ${c.last_name}</strong>
|
||||
<div class="small text-muted">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div>
|
||||
onclick="addContact(${caseId}, ${c.id}, '${fullName.replace(/'/g, "\\'")}')">
|
||||
<strong>${fullName}</strong>
|
||||
<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>
|
||||
`;
|
||||
})()}
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -3098,13 +3098,48 @@
|
||||
}
|
||||
|
||||
/* V3 readability fix: keep menu structure unchanged, restore normal text sizing */
|
||||
#caseTabs {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
|
||||
background: rgba(11, 53, 90, 0.36);
|
||||
border-radius: 10px 10px 0 0;
|
||||
padding: 0.2rem 0.3rem 0;
|
||||
}
|
||||
|
||||
#caseTabs .nav-link {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 0.58rem 0.9rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
#caseTabs .nav-link:hover,
|
||||
#caseTabs .nav-link:focus-visible {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#caseTabs .nav-link.active {
|
||||
color: #0f4c75;
|
||||
background: #ffffff;
|
||||
border-color: rgba(255, 255, 255, 0.78);
|
||||
border-bottom-color: #ffffff;
|
||||
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
|
||||
}
|
||||
|
||||
#caseTabs .nav-link i {
|
||||
font-size: 1rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
#caseTabs .nav-link.active i {
|
||||
color: #0f4c75;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.case-detail-page-shell .header-bg .badge {
|
||||
@ -3357,10 +3392,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="case-detail-page-shell" style="background-color: #f1f5f9; min-height: 100vh;">
|
||||
<div class="case-detail-page-shell" style="background: linear-gradient(to right, #0f4c75, #1e3c72) 0 0 / 100% 420px no-repeat, #ffffff; min-height: 100vh; margin-top: -1.35rem;">
|
||||
|
||||
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
|
||||
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -1rem;">
|
||||
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;">
|
||||
<div class="container-fluid px-3 px-xl-4 position-relative">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@ -3391,7 +3426,7 @@
|
||||
|
||||
<div class="mb-1">
|
||||
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
|
||||
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="letter-spacing: -0.5px;">{{ case.titel }}</h2>
|
||||
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="font-size: 1.55rem; letter-spacing: -0.35px;">{{ case.titel }}</h2>
|
||||
<button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
|
||||
</div>
|
||||
<div id="sag-titel-editor" class="d-none">
|
||||
@ -3415,7 +3450,7 @@
|
||||
</div>
|
||||
|
||||
<!-- WIDGET DASHBOARD -->
|
||||
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -1.25rem;">
|
||||
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -0.95rem;">
|
||||
<div class="row g-2 mb-3">
|
||||
|
||||
<!-- Widget 1: Klassifikation -->
|
||||
@ -3426,7 +3461,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
||||
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" onchange="saveCaseStatusFromTopbar()" style="width: 62%;">
|
||||
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||
{% for st in status_options %}
|
||||
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
||||
{% endfor %}
|
||||
@ -3435,7 +3470,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
|
||||
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" onchange="saveCaseTypeFromTopbar()" style="width: 62%;">
|
||||
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
|
||||
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
|
||||
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
|
||||
@ -3448,7 +3483,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label>
|
||||
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" onchange="saveCasePriorityFromTopbar()" style="width: 62%;">
|
||||
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||
{% set topbar_priority = (case.priority or 'normal')|lower %}
|
||||
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
|
||||
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
|
||||
@ -3468,7 +3503,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label>
|
||||
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
|
||||
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||
<option value="">Ingen bruger</option>
|
||||
{% for user in assignment_users or [] %}
|
||||
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
@ -3478,7 +3513,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label>
|
||||
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
|
||||
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||
<option value="">Ingen gruppe</option>
|
||||
{% for group in assignment_groups or [] %}
|
||||
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||
@ -3514,7 +3549,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label>
|
||||
<div class="input-group input-group-sm" style="width: 62%;">
|
||||
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}" onchange="saveCaseStartDateFromTopbar()">
|
||||
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
||||
@ -3531,7 +3566,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label>
|
||||
<div class="input-group input-group-sm" style="width: 62%;">
|
||||
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}" onchange="updateDeferredUntil(this.value || null)">
|
||||
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
||||
@ -3545,7 +3580,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label>
|
||||
<div class="input-group input-group-sm" style="width: 62%;">
|
||||
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}" onchange="updateDeadline(this.value || null)">
|
||||
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}">
|
||||
<button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@ -3568,8 +3603,8 @@
|
||||
<div id="caseCustomerTopAlerts" class="mb-2 d-none"></div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseAnyDeskModal()"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
|
||||
<button type="button" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseWorkOrderPrint()"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
|
||||
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
|
||||
<button type="button" id="topbarPrintWorkOrderBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -3642,6 +3677,93 @@
|
||||
window.forceCaseTabActivation(tabId);
|
||||
return false;
|
||||
};
|
||||
|
||||
(function bindTopWidgetActions() {
|
||||
const localCaseId = Number({{ case.id }});
|
||||
|
||||
const patchCase = async (payload) => {
|
||||
const response = await fetch(`/api/v1/sag/${localCaseId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const bindChange = (id, handler) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', async () => {
|
||||
try {
|
||||
await handler(el);
|
||||
} catch (error) {
|
||||
console.error(`Top widget change failed for ${id}`, error);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Kunne ikke gemme ændringen', 'danger');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
bindChange('topbarStatusSelect', async (el) => {
|
||||
await patchCase({ status: el.value || 'åben' });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('topbarTypeSelect', async (el) => {
|
||||
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('topbarPrioritySelect', async (el) => {
|
||||
await patchCase({ priority: String(el.value || 'normal').toLowerCase() });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('tabsAssignmentUserSelect', async (el) => {
|
||||
const raw = String(el.value || '').trim();
|
||||
await patchCase({ ansvarlig_bruger_id: raw ? Number(raw) : null });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('tabsAssignmentGroupSelect', async (el) => {
|
||||
const raw = String(el.value || '').trim();
|
||||
await patchCase({ assigned_group_id: raw ? Number(raw) : null });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('topbarStartDateInput', async (el) => {
|
||||
await patchCase({ start_date: el.value || null });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('topbarDeferredInput', async (el) => {
|
||||
await patchCase({ deferred_until: el.value || null });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
bindChange('topbarDeadlineInput', async (el) => {
|
||||
await patchCase({ deadline: el.value || null });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
const printBtn = document.getElementById('topbarPrintWorkOrderBtn');
|
||||
if (printBtn) {
|
||||
printBtn.addEventListener('click', () => {
|
||||
window.open(`/sag/${localCaseId}/work-orders/print`, '_blank', 'noopener');
|
||||
});
|
||||
}
|
||||
|
||||
const anydeskBtn = document.getElementById('caseAnyDeskOpenBtn');
|
||||
if (anydeskBtn && typeof window.openCaseAnyDeskModal === 'function') {
|
||||
anydeskBtn.addEventListener('click', () => {
|
||||
window.openCaseAnyDeskModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
@ -5263,11 +5385,35 @@
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
|
||||
} else {
|
||||
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;"
|
||||
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
|
||||
<strong>${c.first_name} ${c.last_name}</strong>
|
||||
<div class="small text-muted">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div>
|
||||
onclick="addContact(${caseId}, ${c.id}, '${fullName.replace(/'/g, "\\'")}')">
|
||||
<strong>${fullName}</strong>
|
||||
<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>
|
||||
`;
|
||||
})()}
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -26,20 +26,42 @@ async def search_customers(q: str = Query(..., min_length=2)):
|
||||
async def search_contacts(q: str = Query(..., min_length=2)):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
sql = """
|
||||
SELECT id, first_name, last_name, email
|
||||
FROM contacts
|
||||
SELECT
|
||||
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
|
||||
first_name ILIKE %s
|
||||
OR last_name ILIKE %s
|
||||
OR email ILIKE %s
|
||||
OR CONCAT(first_name, ' ', last_name) ILIKE %s
|
||||
OR phone ILIKE %s
|
||||
OR mobile ILIKE %s
|
||||
ORDER BY first_name ASC, last_name ASC
|
||||
c.first_name ILIKE %s
|
||||
OR c.last_name ILIKE %s
|
||||
OR c.email ILIKE %s
|
||||
OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
|
||||
OR c.phone ILIKE %s
|
||||
OR c.mobile ILIKE %s
|
||||
ORDER BY c.first_name ASC, c.last_name ASC
|
||||
LIMIT 20
|
||||
"""
|
||||
term = f"%{q}%"
|
||||
|
||||
@ -3,7 +3,7 @@ import logging
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.error import URLError, HTTPError
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
@ -653,29 +653,15 @@ async def list_calls(
|
||||
where = []
|
||||
params = []
|
||||
|
||||
parsed_date_from = None
|
||||
parsed_date_to = None
|
||||
if date_from:
|
||||
try:
|
||||
parsed_date_from = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid date_from format, expected YYYY-MM-DD")
|
||||
if date_to:
|
||||
try:
|
||||
# Make date_to inclusive for the whole selected day.
|
||||
parsed_date_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid date_to format, expected YYYY-MM-DD")
|
||||
|
||||
if user_id is not None:
|
||||
where.append("t.bruger_id = %s")
|
||||
params.append(user_id)
|
||||
if parsed_date_from is not None:
|
||||
if date_from:
|
||||
where.append("t.started_at >= %s")
|
||||
params.append(parsed_date_from)
|
||||
if parsed_date_to is not None:
|
||||
where.append("t.started_at < %s")
|
||||
params.append(parsed_date_to)
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where.append("t.started_at <= %s")
|
||||
params.append(date_to)
|
||||
if without_case:
|
||||
where.append("t.sag_id IS NULL")
|
||||
|
||||
|
||||
@ -58,42 +58,8 @@
|
||||
<th class="text-end">Varighed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="telefoniRows" data-initial-count="{{ initial_calls|length if initial_calls else 0 }}">
|
||||
{% if initial_calls and initial_calls|length > 0 %}
|
||||
{% for r in initial_calls %}
|
||||
<tr>
|
||||
<td>{{ r.started_at or '-' }}</td>
|
||||
<td>{{ r.full_name or r.username or '-' }}</td>
|
||||
<td>{{ 'Udgående' if r.direction == 'outbound' else 'Indgående' }}</td>
|
||||
<td>{{ r.display_number or '-' }}</td>
|
||||
<td>
|
||||
{% if r.kontakt_id %}
|
||||
<a href="/contacts/{{ r.kontakt_id }}">{{ r.contact_name or ('Kontakt #' ~ r.kontakt_id) }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.sag_id %}
|
||||
<a href="/sag/{{ r.sag_id }}/v3">{{ r.sag_titel or ('Sag #' ~ r.sag_id) }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if r.duration_sec is not none %}
|
||||
{{ r.duration_sec }}s
|
||||
{% elif r.ended_at %}
|
||||
-
|
||||
{% else %}
|
||||
I gang
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
|
||||
{% endif %}
|
||||
<tbody id="telefoniRows">
|
||||
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -212,8 +178,6 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
let telefoniCurrentUserId = null;
|
||||
let telefoniAutoResetTried = false;
|
||||
let telefoniFirstApiLoadDone = false;
|
||||
const telefoniCallMap = new Map();
|
||||
const linkSagState = {
|
||||
callId: null,
|
||||
@ -858,8 +822,6 @@ async function loadUsers() {
|
||||
|
||||
async function loadCalls() {
|
||||
const tbody = document.getElementById('telefoniRows');
|
||||
const initialCount = Number(tbody?.dataset?.initialCount || '0');
|
||||
const hadServerRows = Number.isFinite(initialCount) && initialCount > 0;
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
|
||||
|
||||
const userId = document.getElementById('filterUser').value;
|
||||
@ -884,31 +846,10 @@ async function loadCalls() {
|
||||
telefoniCallMap.clear();
|
||||
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
|
||||
if (!rows || rows.length === 0) {
|
||||
const hadFilters = Boolean(userId || from || to || withoutCase);
|
||||
|
||||
// If SSR already showed calls, avoid replacing them with an empty first auto-refresh.
|
||||
if (!telefoniFirstApiLoadDone && hadServerRows && !hadFilters) {
|
||||
telefoniFirstApiLoadDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hadFilters && !telefoniAutoResetTried) {
|
||||
telefoniAutoResetTried = true;
|
||||
document.getElementById('filterUser').value = '';
|
||||
document.getElementById('filterFrom').value = '';
|
||||
document.getElementById('filterTo').value = '';
|
||||
document.getElementById('filterWithoutCase').checked = false;
|
||||
await loadCalls();
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
|
||||
telefoniFirstApiLoadDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
telefoniAutoResetTried = false;
|
||||
telefoniFirstApiLoadDone = true;
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at) : null;
|
||||
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
|
||||
@ -1033,7 +974,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (fromFilter) fromFilter.value = '';
|
||||
if (toFilter) toFilter.value = '';
|
||||
if (withoutCaseFilter) withoutCaseFilter.checked = false;
|
||||
telefoniAutoResetTried = false;
|
||||
|
||||
await loadUsers();
|
||||
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
|
||||
|
||||
@ -235,6 +235,178 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens:
|
||||
return score
|
||||
|
||||
|
||||
def _normalize_lookup_code(raw: Optional[str]) -> str:
|
||||
return "".join(ch for ch in str(raw or "") if ch.isalnum()).lower()
|
||||
|
||||
|
||||
def _find_local_product_by_lookup(code: str) -> Optional[Dict[str, Any]]:
|
||||
normalized = _normalize_lookup_code(code)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
query = """
|
||||
SELECT *
|
||||
FROM products
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
LOWER(REGEXP_REPLACE(COALESCE(ean, ''), '[^a-zA-Z0-9]', '', 'g')) = %s
|
||||
OR LOWER(REGEXP_REPLACE(COALESCE(sku_internal, ''), '[^a-zA-Z0-9]', '', 'g')) = %s
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN status = 'active' THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
return execute_query_single(query, (normalized, normalized))
|
||||
|
||||
|
||||
def _pick_best_apigw_match(products: List[Dict[str, Any]], query: str) -> Optional[Dict[str, Any]]:
|
||||
if not products:
|
||||
return None
|
||||
|
||||
normalized_query = _normalize_lookup_code(query)
|
||||
if not normalized_query:
|
||||
return products[0]
|
||||
|
||||
exact_matches: List[Dict[str, Any]] = []
|
||||
sku_matches: List[Dict[str, Any]] = []
|
||||
for product in products:
|
||||
ean_norm = _normalize_lookup_code(product.get("ean"))
|
||||
sku_norm = _normalize_lookup_code(product.get("sku"))
|
||||
if ean_norm and ean_norm == normalized_query:
|
||||
exact_matches.append(product)
|
||||
elif sku_norm and sku_norm == normalized_query:
|
||||
sku_matches.append(product)
|
||||
|
||||
if exact_matches:
|
||||
return exact_matches[0]
|
||||
if sku_matches:
|
||||
return sku_matches[0]
|
||||
return products[0]
|
||||
|
||||
|
||||
def _get_customer_margin_percent(customer_id: Optional[int]) -> Optional[float]:
|
||||
if not customer_id:
|
||||
return None
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT standard_margin_percent FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
return None
|
||||
|
||||
margin_raw = customer.get("standard_margin_percent")
|
||||
if margin_raw is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(margin_raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_sales_price_with_margin(base_price: Any, margin_percent: Optional[float]) -> Optional[float]:
|
||||
if base_price is None:
|
||||
return None
|
||||
try:
|
||||
base = float(base_price)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if margin_percent is None:
|
||||
return base
|
||||
|
||||
price = base * (1 + (float(margin_percent) / 100.0))
|
||||
return round(price, 2)
|
||||
|
||||
|
||||
def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
product = payload.get("product") or payload
|
||||
name = (product.get("product_name") or product.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="product_name is required")
|
||||
|
||||
supplier_code = product.get("supplier_code")
|
||||
sku = product.get("sku")
|
||||
sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku
|
||||
|
||||
if sku_internal:
|
||||
existing_by_sku = execute_query_single(
|
||||
"SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL",
|
||||
(sku_internal,)
|
||||
)
|
||||
if existing_by_sku:
|
||||
_upsert_product_supplier(existing_by_sku["id"], product, source="gateway")
|
||||
return existing_by_sku
|
||||
|
||||
ean = (product.get("ean") or "").strip()
|
||||
if ean:
|
||||
existing_by_ean = execute_query_single(
|
||||
"SELECT * FROM products WHERE ean = %s AND deleted_at IS NULL",
|
||||
(ean,)
|
||||
)
|
||||
if existing_by_ean:
|
||||
_upsert_product_supplier(existing_by_ean["id"], product, source="gateway")
|
||||
return existing_by_ean
|
||||
|
||||
margin_percent = _get_customer_margin_percent(customer_id)
|
||||
sales_price = _calculate_sales_price_with_margin(product.get("price"), margin_percent)
|
||||
supplier_price = product.get("price")
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO products (
|
||||
name,
|
||||
short_description,
|
||||
type,
|
||||
status,
|
||||
sku_internal,
|
||||
ean,
|
||||
manufacturer,
|
||||
supplier_name,
|
||||
supplier_sku,
|
||||
supplier_price,
|
||||
supplier_currency,
|
||||
supplier_stock,
|
||||
sales_price,
|
||||
vat_rate,
|
||||
billable
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
name,
|
||||
product.get("category"),
|
||||
"hardware",
|
||||
"active",
|
||||
sku_internal,
|
||||
ean or None,
|
||||
product.get("manufacturer"),
|
||||
product.get("supplier_name"),
|
||||
sku,
|
||||
supplier_price,
|
||||
product.get("currency") or "DKK",
|
||||
product.get("stock_qty"),
|
||||
sales_price,
|
||||
25.00,
|
||||
True,
|
||||
)
|
||||
result = execute_query(insert_query, params)
|
||||
created = result[0] if result else {}
|
||||
if created:
|
||||
_upsert_product_supplier(created["id"], product, source="gateway")
|
||||
|
||||
if created and margin_percent is not None:
|
||||
created["applied_customer_margin_percent"] = margin_percent
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/products/apigateway/search", response_model=Dict[str, Any])
|
||||
async def search_apigw_products(
|
||||
q: Optional[str] = Query(None),
|
||||
@ -310,75 +482,93 @@ async def search_apigw_products(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/products/search/apigateway-sync", response_model=Dict[str, Any])
|
||||
async def search_or_create_product_from_apigw(
|
||||
code: str = Query(..., min_length=2),
|
||||
auto_create: bool = Query(True),
|
||||
customer_id: Optional[int] = Query(None),
|
||||
):
|
||||
"""
|
||||
Local-first product lookup by EAN/SKU-like code.
|
||||
If not found locally, search APIGateway and optionally auto-create locally from best match.
|
||||
"""
|
||||
search_code = (code or "").strip()
|
||||
if not search_code:
|
||||
raise HTTPException(status_code=400, detail="code is required")
|
||||
|
||||
local_product = _find_local_product_by_lookup(search_code)
|
||||
if local_product:
|
||||
return {
|
||||
"found": True,
|
||||
"source": "local",
|
||||
"created": False,
|
||||
"query": search_code,
|
||||
"product": local_product,
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
|
||||
url = f"{_apigw_base_url()}/api/v1/products/search"
|
||||
params = {"q": search_code, "per_page": 25}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=_apigw_headers(), params=params) as response:
|
||||
if response.status >= 400:
|
||||
detail = await _read_apigw_error(response)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"API Gateway product search failed ({response.status}): {detail}",
|
||||
)
|
||||
data = await response.json()
|
||||
except HTTPException:
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="API Gateway product search timed out")
|
||||
except aiohttp.ClientError as e:
|
||||
raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error("❌ APIGW sync search failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
products = data.get("products") if isinstance(data, dict) else []
|
||||
products = products if isinstance(products, list) else []
|
||||
best_match = _pick_best_apigw_match(products, search_code)
|
||||
if not best_match:
|
||||
return {
|
||||
"found": False,
|
||||
"source": "apigateway",
|
||||
"created": False,
|
||||
"query": search_code,
|
||||
"message": "Ingen match i APIGateway",
|
||||
}
|
||||
|
||||
if not auto_create:
|
||||
return {
|
||||
"found": True,
|
||||
"source": "apigateway",
|
||||
"created": False,
|
||||
"query": search_code,
|
||||
"product": best_match,
|
||||
}
|
||||
|
||||
created_or_existing = _import_apigw_product_to_local(best_match, customer_id=customer_id)
|
||||
applied_margin = created_or_existing.get("applied_customer_margin_percent") if isinstance(created_or_existing, dict) else None
|
||||
return {
|
||||
"found": True,
|
||||
"source": "apigateway",
|
||||
"created": True,
|
||||
"query": search_code,
|
||||
"customer_id": customer_id,
|
||||
"applied_customer_margin_percent": applied_margin,
|
||||
"product": created_or_existing,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/products/apigateway/import", response_model=Dict[str, Any])
|
||||
async def import_apigw_product(payload: Dict[str, Any]):
|
||||
"""Import a single APIGW product into local catalog."""
|
||||
try:
|
||||
product = payload.get("product") or payload
|
||||
name = (product.get("product_name") or product.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="product_name is required")
|
||||
|
||||
supplier_code = product.get("supplier_code")
|
||||
sku = product.get("sku")
|
||||
sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku
|
||||
|
||||
if sku_internal:
|
||||
existing = execute_query_single(
|
||||
"SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL",
|
||||
(sku_internal,)
|
||||
)
|
||||
if existing:
|
||||
_upsert_product_supplier(existing["id"], product, source="gateway")
|
||||
return existing
|
||||
|
||||
sales_price = product.get("price")
|
||||
supplier_price = product.get("price")
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO products (
|
||||
name,
|
||||
short_description,
|
||||
type,
|
||||
status,
|
||||
sku_internal,
|
||||
ean,
|
||||
manufacturer,
|
||||
supplier_name,
|
||||
supplier_sku,
|
||||
supplier_price,
|
||||
supplier_currency,
|
||||
supplier_stock,
|
||||
sales_price,
|
||||
vat_rate,
|
||||
billable
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
name,
|
||||
product.get("category"),
|
||||
"hardware",
|
||||
"active",
|
||||
sku_internal,
|
||||
product.get("ean"),
|
||||
product.get("manufacturer"),
|
||||
product.get("supplier_name"),
|
||||
sku,
|
||||
supplier_price,
|
||||
product.get("currency") or "DKK",
|
||||
product.get("stock_qty"),
|
||||
sales_price,
|
||||
25.00,
|
||||
True,
|
||||
)
|
||||
result = execute_query(insert_query, params)
|
||||
created = result[0] if result else {}
|
||||
if created:
|
||||
_upsert_product_supplier(created["id"], product, source="gateway")
|
||||
return created
|
||||
return _import_apigw_product_to_local(payload)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@ -210,7 +210,7 @@
|
||||
<div class="products-search">
|
||||
<div class="input-group" style="min-width: 260px;">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter...">
|
||||
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter eller EAN/strengkode...">
|
||||
</div>
|
||||
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
|
||||
<option value="active" selected>Aktiv</option>
|
||||
@ -234,6 +234,9 @@
|
||||
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
|
||||
<i class="bi bi-search"></i> Soeg
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
|
||||
<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
|
||||
<i class="bi bi-x"></i> Nulstil
|
||||
</button>
|
||||
@ -593,6 +596,67 @@ function applyLocalProductSearch() {
|
||||
loadProducts();
|
||||
}
|
||||
|
||||
function showLocalProductMessage(message, level = 'info') {
|
||||
const meta = document.getElementById('localProductsMeta');
|
||||
if (!meta) return;
|
||||
const prefix = level === 'error' ? 'Fejl: ' : level === 'success' ? 'OK: ' : '';
|
||||
meta.textContent = `${prefix}${message}`;
|
||||
}
|
||||
|
||||
async function searchAndCreateByGatewayCode() {
|
||||
const queryInput = document.getElementById('localSearchQuery');
|
||||
const button = document.getElementById('gatewayBarcodeSyncBtn');
|
||||
const code = queryInput ? queryInput.value.trim() : '';
|
||||
|
||||
if (!code) {
|
||||
alert('Skriv EAN eller strengkode i soegefeltet foerst');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHtml = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Soeger...';
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ code, auto_create: 'true' });
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.detail || 'Sogning i APIGateway fejlede');
|
||||
}
|
||||
|
||||
if (!body.found) {
|
||||
showLocalProductMessage('Ingen match i APIGateway for den strengkode', 'error');
|
||||
alert('Ingen produkt fundet i APIGateway for den strengkode');
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.source === 'local') {
|
||||
showLocalProductMessage('Produkt fundet lokalt', 'success');
|
||||
} else if (body.created) {
|
||||
showLocalProductMessage('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
|
||||
} else {
|
||||
showLocalProductMessage('Produkt fundet via APIGateway', 'info');
|
||||
}
|
||||
|
||||
await loadProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showLocalProductMessage(error.message || 'Sogning fejlede', 'error');
|
||||
alert(error.message || 'Sogning fejlede');
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalProductSearch() {
|
||||
const fields = [
|
||||
'localSearchQuery',
|
||||
@ -654,7 +718,11 @@ function setupLocalSearchEvents() {
|
||||
el.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
applyLocalProductSearch();
|
||||
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
|
||||
searchAndCreateByGatewayCode();
|
||||
} else {
|
||||
applyLocalProductSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
el.addEventListener('input', triggerSearch);
|
||||
|
||||
@ -813,7 +813,6 @@
|
||||
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/support/fedex"><i class="bi bi-truck me-2"></i>FedEx Overblik</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
||||
@ -846,7 +845,6 @@
|
||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
||||
@ -870,7 +868,6 @@
|
||||
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard2"><i class="bi bi-check2-square me-2"></i>Godkend Tider V2</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
|
||||
53
app/shared/frontend/bug_report_modal.html
Normal file
53
app/shared/frontend/bug_report_modal.html
Normal 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>
|
||||
@ -254,15 +254,69 @@ class EconomicExportService:
|
||||
(order['customer_id'],)
|
||||
)
|
||||
if not check_link or not check_link.get('hub_customer_id'):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Customer '{order['customer_name']}' er ikke linket til en Hub kunde. Gå til vTiger kunder og link kunden først."
|
||||
message = (
|
||||
f"LOCAL-ONLY: Customer '{order['customer_name']}' er ikke linket til en Hub kunde. "
|
||||
"Eksport til e-conomic er sprunget over."
|
||||
)
|
||||
logger.warning("⚠️ %s", message)
|
||||
|
||||
result = TModuleEconomicExportResult(
|
||||
success=True,
|
||||
dry_run=True,
|
||||
order_id=request.order_id,
|
||||
economic_draft_id=None,
|
||||
economic_order_number=None,
|
||||
message=message,
|
||||
details={
|
||||
"skipped_reason": "missing_hub_customer_link",
|
||||
"order_number": order['order_number'],
|
||||
"customer_name": order['customer_name'],
|
||||
"read_only": self.read_only,
|
||||
"dry_run": True,
|
||||
}
|
||||
)
|
||||
|
||||
audit.log_export_completed(
|
||||
order_id=request.order_id,
|
||||
economic_draft_id=None,
|
||||
economic_order_number=None,
|
||||
dry_run=True,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Customer '{order['customer_name']}' mangler e-conomic kundenummer. Opdater kunde i Customers modulet."
|
||||
message = (
|
||||
f"LOCAL-ONLY: Customer '{order['customer_name']}' mangler e-conomic kundenummer. "
|
||||
"Eksport til e-conomic er sprunget over."
|
||||
)
|
||||
logger.warning("⚠️ %s", message)
|
||||
|
||||
result = TModuleEconomicExportResult(
|
||||
success=True,
|
||||
dry_run=True,
|
||||
order_id=request.order_id,
|
||||
economic_draft_id=None,
|
||||
economic_order_number=None,
|
||||
message=message,
|
||||
details={
|
||||
"skipped_reason": "missing_economic_customer_number",
|
||||
"order_number": order['order_number'],
|
||||
"customer_name": order['customer_name'],
|
||||
"read_only": self.read_only,
|
||||
"dry_run": True,
|
||||
}
|
||||
)
|
||||
|
||||
audit.log_export_completed(
|
||||
order_id=request.order_id,
|
||||
economic_draft_id=None,
|
||||
economic_order_number=None,
|
||||
dry_run=True,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
customer_number = customer_data['economic_customer_number']
|
||||
|
||||
|
||||
@ -222,6 +222,9 @@
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" id="create-order-btn" onclick="createOrderFromApproved()">
|
||||
<i class="bi bi-receipt"></i> Opret ordre
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="rejectSelected()">
|
||||
<i class="bi bi-x-circle"></i> Afvis Valgte
|
||||
</button>
|
||||
@ -599,10 +602,19 @@
|
||||
function updateSummary() {
|
||||
const name = currentCustomerData?.customer_name || 'Kunde';
|
||||
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
|
||||
const approvedCount = parseInt(currentCustomerData?.approved_count || 0, 10);
|
||||
const createOrderBtn = document.getElementById('create-order-btn');
|
||||
|
||||
document.getElementById('customer-name').textContent = name;
|
||||
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
|
||||
document.getElementById('pending-count').textContent = pendingEntries.length;
|
||||
|
||||
if (createOrderBtn) {
|
||||
createOrderBtn.disabled = approvedCount <= 0;
|
||||
createOrderBtn.title = approvedCount > 0
|
||||
? `Opret ordre fra ${approvedCount} godkendte registreringer`
|
||||
: 'Ingen godkendte registreringer endnu';
|
||||
}
|
||||
|
||||
let totalValue = 0;
|
||||
pendingEntries.forEach(entry => {
|
||||
@ -835,6 +847,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrderFromApproved() {
|
||||
if (!currentCustomerId) {
|
||||
alert('Vælg først en kunde.');
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedCount = parseInt(currentCustomerData?.approved_count || 0, 10);
|
||||
if (approvedCount <= 0) {
|
||||
alert('Der er ingen godkendte registreringer at oprette ordre fra.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Opret ordre fra ${approvedCount} godkendte registreringer?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.getElementById('create-order-btn');
|
||||
const originalText = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/timetracking/orders/generate/${currentCustomerId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Fejl ved oprettelse af ordre');
|
||||
}
|
||||
|
||||
alert(`✅ Ordre oprettet: ${result.order_number}\n\nTotal: ${parseFloat(result.total_amount || 0).toFixed(2)} DKK`);
|
||||
window.location.href = '/timetracking/orders';
|
||||
} catch (error) {
|
||||
alert('❌ Kunne ikke oprette ordre:\n\n' + error.message);
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectSelected() {
|
||||
const ids = Array.from(selectedEntries);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
90
fix_telefoni_per_krag_numbers.sql
Normal file
90
fix_telefoni_per_krag_numbers.sql
Normal 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;
|
||||
4
main.py
4
main.py
@ -139,8 +139,6 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
|
||||
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
|
||||
from app.modules.rentals.backend import router as rentals_api
|
||||
from app.modules.task_templates.backend import router as task_templates_api
|
||||
from app.economy.backend import router as economy_api
|
||||
from app.economy.frontend import views as economy_views
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -459,7 +457,6 @@ app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bo
|
||||
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
||||
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.backend import router as links_api
|
||||
@ -495,7 +492,6 @@ app.include_router(orders_views.router, tags=["Frontend"])
|
||||
app.include_router(fedex_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
app.include_router(manual_views.router, tags=["Frontend"])
|
||||
app.include_router(economy_views.router, tags=["Frontend"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
|
||||
15
migrations/1004_bug_report_submissions.sql
Normal file
15
migrations/1004_bug_report_submissions.sql
Normal 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);
|
||||
9
migrations/184_locations_contacts_related_contact.sql
Normal file
9
migrations/184_locations_contacts_related_contact.sql
Normal 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);
|
||||
26
migrations/185_locations_contacts_unique_related.sql
Normal file
26
migrations/185_locations_contacts_unique_related.sql
Normal 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;
|
||||
16
migrations/186_customers_economic_pricing_fields.sql
Normal file
16
migrations/186_customers_economic_pricing_fields.sql
Normal 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.';
|
||||
7
migrations/187_customers_standard_hourly_rate.sql
Normal file
7
migrations/187_customers_standard_hourly_rate.sql
Normal 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
489
static/js/bug-report.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
10
updateto.sh
10
updateto.sh
@ -28,9 +28,6 @@ fi
|
||||
CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown")
|
||||
CURRENT_DIR=$(pwd)
|
||||
DEFAULT_STACK_NAME="prod"
|
||||
if [ "$CURRENT_DIR" = "/srv/podman/bmc_hub_v2" ]; then
|
||||
DEFAULT_STACK_NAME="v2"
|
||||
fi
|
||||
|
||||
if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then
|
||||
echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!"
|
||||
@ -166,9 +163,14 @@ fi
|
||||
# Guard against host port conflicts before attempting startup
|
||||
POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}"
|
||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
if podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" >/dev/null 2>&1; then
|
||||
PORT_HOLDERS=$(podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" || true)
|
||||
if [ -n "$PORT_HOLDERS" ]; then
|
||||
echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)"
|
||||
echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433"
|
||||
if echo "$PORT_HOLDERS" | grep -q 'bmc-hub-postgres-prod' && [ "$STACK_NAME" != "prod" ]; then
|
||||
echo " Tip: Aktiv prod-stack fundet. Kør med korrekt stack-navn:"
|
||||
echo " STACK_NAME=prod ./updateto.sh $VERSION"
|
||||
fi
|
||||
podman ps --format 'table {{.Names}}\t{{.Ports}}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Loading…
Reference in New Issue
Block a user