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