Compare commits

..

No commits in common. "main" and "feature/sag-tidsforbrug-v1" have entirely different histories.

286 changed files with 2905 additions and 71420 deletions

View File

@ -16,16 +16,6 @@ API_HOST=0.0.0.0
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
# Customer default economics (used as fallback defaults in customer detail)
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=
FIRMAAPI_TIMEOUT_SECONDS=12
# =====================================================
# SECURITY
# =====================================================
@ -69,20 +59,6 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
# =====================================================
# FedEx Integration (Optional)
# =====================================================
FEDEX_ENABLED=false
FEDEX_API_KEY=
FEDEX_API_SECRET=
FEDEX_ACCOUNT_NUMBER=
FEDEX_BASE_URL=
FEDEX_TIMEOUT_SECONDS=20
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser
FEDEX_READ_ONLY=true
FEDEX_DRY_RUN=true
# =====================================================
# Nextcloud Integration (Optional)
# =====================================================
@ -93,20 +69,6 @@ NEXTCLOUD_CACHE_TTL_SECONDS=300
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
NEXTCLOUD_ENCRYPTION_KEY=
# =====================================================
# Links / Endpoints Module (Optional)
# =====================================================
LINKS_MODULE_ENABLED=false
LINKS_READ_ONLY=true
LINKS_DRY_RUN=true
LINKS_DEAD_LINK_CHECK_ENABLED=true
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
LINKS_CHECK_TIMEOUT_SECONDS=5
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL=
VAULTWARDEN_API_TOKEN=
# =====================================================
# vTiger Cloud Integration (Required for Subscriptions)
# =====================================================
@ -149,8 +111,6 @@ EMAIL_RULES_AUTO_PROCESS=false
EMAIL_AI_ENABLED=false
EMAIL_AUTO_CLASSIFY=false
EMAIL_AI_CONFIDENCE_THRESHOLD=0.7
EMAIL_REQUIRE_MANUAL_APPROVAL=true
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL=false
EMAIL_MAX_FETCH_PER_RUN=50
EMAIL_PROCESS_INTERVAL_MINUTES=5
EMAIL_WORKFLOWS_ENABLED=true

View File

@ -44,16 +44,6 @@ API_HOST=0.0.0.0
API_PORT=8000
API_RELOAD=false
# Customer default economics (used as fallback defaults in customer detail)
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=
FIRMAAPI_TIMEOUT_SECONDS=12
# =====================================================
# SECURITY - Production
# =====================================================
@ -86,33 +76,3 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
# VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer på samme linje
ECONOMIC_READ_ONLY=true
ECONOMIC_DRY_RUN=true
# =====================================================
# FedEx Integration - Production
# =====================================================
FEDEX_ENABLED=false
FEDEX_API_KEY=
FEDEX_API_SECRET=
FEDEX_ACCOUNT_NUMBER=
FEDEX_BASE_URL=
FEDEX_TIMEOUT_SECONDS=20
# 🚨 SAFETY SWITCHES
# Start ALTID med begge sat til true i ny production deployment!
FEDEX_READ_ONLY=true
FEDEX_DRY_RUN=true
# =====================================================
# Links / Endpoints Module - Production (Optional)
# =====================================================
# Start disabled; enable after migration + validation
LINKS_MODULE_ENABLED=false
LINKS_READ_ONLY=true
LINKS_DRY_RUN=true
LINKS_DEAD_LINK_CHECK_ENABLED=true
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
LINKS_CHECK_TIMEOUT_SECONDS=5
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL=
VAULTWARDEN_API_TOKEN=

View File

@ -7,7 +7,6 @@ RUN apt-get update && apt-get install -y \
curl \
git \
libpq-dev \
libzbar0 \
gcc \
g++ \
python3-dev \

View File

@ -1,29 +0,0 @@
# Release Notes v2.2.81
Dato: 2026-05-04
## Fixes
- Kontakter: Stabiliseret paginering i `/api/v1/contacts` ved at tilfoeje deterministisk tie-break (`ORDER BY ... , c.id`).
- Kontakter: Fjernet skrøbelig frontend query-key short-circuit i kontaktlisten, som kunne medfoere at listen ikke blev genindlaest korrekt efter afbrudte requests.
- Telefoni: Rettet datofilter i `/api/v1/telefoni/calls` saa `date_to` er inklusiv hele dagen.
- Telefoni: Validerer nu tydeligt `date_from`/`date_to` format (`YYYY-MM-DD`) med 422 ved ugyldig input.
- Deployment: `updateto.sh` bruger nu dynamiske containernavne baseret paa `STACK_NAME` i stedet for hardcoded `-prod`.
## Beroerte filer
- `app/contacts/backend/router_simple.py`
- `app/contacts/frontend/contacts.html`
- `app/modules/telefoni/backend/router.py`
- `updateto.sh`
- `VERSION`
## Drift
Hvis stacken koerer som `v2`, deploy med:
```bash
sudo -iu bmcadmin
cd /srv/podman/bmc_hub_v2
./updateto.sh v2.2.81
```

View File

@ -1,21 +0,0 @@
# Release Notes v2.2.82
Dato: 2026-05-04
## Hotfix
- `updateto.sh` fjerner nu automatisk `STACK_NAME` fra `.env` inden startup.
- `updateto.sh` vaelger automatisk `STACK_NAME=v2` i `/srv/podman/bmc_hub_v2` (ellers `prod`).
## Hvorfor
Nogle prod-deployments crashede API ved startup med:
- `ValidationError: STACK_NAME Extra inputs are not permitted`
Aarsagen var, at `STACK_NAME` laa i `.env` og blev indlaest af FastAPI Settings.
## Berort fil
- `updateto.sh`
- `VERSION`

View File

@ -1,14 +0,0 @@
# Release Notes v2.2.83
Dato: 2026-05-04
## Hotfix
- `updateto.sh` loader nu `.env` sikkert uden `source`.
- Deploy fejler ikke laengere med shell-fejl som fx `Hub: command not found` ved ugyldige tekstlinjer i `.env`.
- Scriptet giver nu tydelig linjenummer-fejl ved ugyldige `.env` linjer.
## Berorte filer
- `updateto.sh`
- `VERSION`

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
2.2.99
2.2.52

View File

@ -1,142 +0,0 @@
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
css_start = text.find('<style>')
if css_start != -1:
css_new = '''<style>
.time-v1-calendar-container {
background: var(--bg-surface, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
margin-bottom: 2rem;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
}
.time-v1-calendar-header {
background: var(--bg-element, #f8f9fa);
border-bottom: 1px solid var(--border-color, #e0e0e0);
padding: 12px 20px;
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color);
}
.time-v1-calendar-grid {
display: flex;
position: relative;
overflow-x: auto;
}
.time-v1-time-axis {
width: 60px;
flex-shrink: 0;
border-right: 1px solid var(--border-color, #f0f0f0);
position: relative;
background: var(--bg-element, #fafafa);
padding-top: 40px;
}
.time-v1-hour-marker {
position: absolute;
width: 100%;
text-align: center;
font-size: 0.75rem;
color: var(--text-secondary);
transform: translateY(-50%);
}
.time-v1-tech-col {
flex: 1;
min-width: 250px;
border-right: 1px solid var(--border-color, #f0f0f0);
position: relative;
}
.time-v1-tech-col:last-child {
border-right: none;
}
.time-v1-tech-header {
text-align: center;
padding: 8px;
height: 40px;
font-weight: 600;
font-size: 0.85rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-element, #f8f9fa);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
position: sticky;
top: 0;
z-index: 50;
color: var(--text-color);
}
.time-v1-tech-body {
position: relative;
height: 600px;
background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px);
background-size: 100% 60px;
}
.time-v1-entry-block {
position: absolute;
left: 4px;
right: 4px;
border-radius: 6px;
padding: 6px 8px;
font-size: 0.8rem;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s;
border-left: 4px solid var(--bs-secondary);
background: var(--bg-surface, #fff);
cursor: grab;
z-index: 10;
}
.time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; }
.time-v1-entry-block:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
z-index: 20;
}
.time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; }
.time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; }
.time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; }
.time-v1-entry-time {
font-weight: 600;
font-size: 0.75rem;
margin-bottom: 2px;
color: var(--text-color);
}
.time-v1-entry-desc {
color: var(--text-secondary);
font-size: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.time-v1-unplaced-container {
padding: 12px 20px;
border-top: 1px solid var(--border-color);
background: var(--bg-element);
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.time-v1-unplaced-item {
background: var(--bg-surface);
border: 1px solid var(--border-color);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-color);
}
'''
text = text[:css_start] + css_new + text[css_start+7:]
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print('CSS added successfully!')

View File

@ -1,14 +0,0 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/anydesk/sessions", response_class=HTMLResponse, tags=["Frontend"])
async def anydesk_sessions_page(request: Request):
return templates.TemplateResponse(
"anydesk/frontend/sessions.html",
{"request": request, "page_title": "AnyDesk Sessions"},
)

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@ from typing import Optional
from app.core.auth_service import AuthService
from app.core.config import settings
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query
import logging
logger = logging.getLogger(__name__)
@ -208,101 +207,3 @@ async def disable_2fa(
)
return {"message": "2FA disabled"}
# ─── User Profile ─────────────────────────────────────────────────────────────
class UserProfileUpdate(BaseModel):
full_name: Optional[str] = None
phone: Optional[str] = None
title: Optional[str] = None
anydesk_id: Optional[str] = None
@router.get("/me/profile")
async def get_my_profile(current_user: dict = Depends(get_current_user)):
"""Get current user's extended profile fields"""
rows = execute_query(
"SELECT full_name, phone, title, anydesk_id FROM users WHERE user_id = %s",
(current_user["id"],)
)
if not rows:
raise HTTPException(status_code=404, detail="User not found")
return dict(rows[0])
@router.patch("/me/profile")
async def update_my_profile(
payload: UserProfileUpdate,
current_user: dict = Depends(get_current_user)
):
"""Update current user's profile fields"""
fields = []
values = []
if payload.full_name is not None:
fields.append("full_name = %s")
values.append(payload.full_name.strip() or None)
if payload.phone is not None:
fields.append("phone = %s")
values.append(payload.phone.strip() or None)
if payload.title is not None:
fields.append("title = %s")
values.append(payload.title.strip() or None)
if payload.anydesk_id is not None:
fields.append("anydesk_id = %s")
values.append(payload.anydesk_id.strip() or None)
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields.append("updated_at = NOW()")
values.append(current_user["id"])
execute_query(
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
tuple(values)
)
return {"message": "Profil opdateret"}
# ─── User AnyDesk IDs (multiple per technician) ───────────────────────────────
class AnyDeskIdAdd(BaseModel):
anydesk_id: str
label: Optional[str] = None
@router.get("/me/anydesk-ids")
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
rows = execute_query(
"SELECT id, anydesk_id, label, created_at FROM user_anydesk_ids WHERE user_id = %s ORDER BY created_at",
(current_user["id"],)
)
return {"ids": [dict(r) for r in (rows or [])]}
@router.post("/me/anydesk-ids", status_code=201)
async def add_my_anydesk_id(payload: AnyDeskIdAdd, current_user: dict = Depends(get_current_user)):
ad_id = payload.anydesk_id.strip()
if not ad_id:
raise HTTPException(status_code=400, detail="anydesk_id cannot be empty")
try:
execute_query(
"INSERT INTO user_anydesk_ids (user_id, anydesk_id, label) VALUES (%s, %s, %s)",
(current_user["id"], ad_id, payload.label or None)
)
except Exception:
raise HTTPException(status_code=409, detail="AnyDesk ID allerede tilføjet")
return {"message": "Tilføjet"}
@router.delete("/me/anydesk-ids/{entry_id}")
async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_current_user)):
rows = execute_query(
"DELETE FROM user_anydesk_ids WHERE id = %s AND user_id = %s RETURNING id",
(entry_id, current_user["id"])
)
if not rows:
raise HTTPException(status_code=404, detail="Ikke fundet")
return {"message": "Slettet"}

View File

@ -126,25 +126,7 @@ async def create_backup(backup: BackupCreate):
"message": "Full backup created successfully"
}
else:
db_error = None
failed_db_row = execute_query_single(
"""
SELECT id, error_message
FROM backup_jobs
WHERE job_type = 'database'
AND status = 'failed'
ORDER BY created_at DESC
LIMIT 1
"""
)
if failed_db_row:
db_error = failed_db_row.get("error_message")
detail = f"Full backup partially failed: db={db_job_id}, files={files_job_id}"
if db_error:
detail = f"{detail}. Database error: {db_error}"
raise HTTPException(status_code=500, detail=detail)
raise HTTPException(status_code=500, detail=f"Full backup partially failed: db={db_job_id}, files={files_job_id}")
else:
raise HTTPException(status_code=400, detail="Invalid job_type. Must be: database, files, or full")

View File

@ -8,7 +8,6 @@ import logging
import hashlib
import tarfile
import subprocess
import shutil
import fcntl
from pathlib import Path
from datetime import datetime, timedelta
@ -53,29 +52,6 @@ class BackupService:
self.db_dir.mkdir(exist_ok=True)
self.files_dir.mkdir(exist_ok=True)
def _resolve_pg_binary(self, binary_name: str) -> str:
"""Resolve PostgreSQL CLI binaries across container/host environments."""
found = shutil.which(binary_name)
if found:
return found
candidates = [
f"/usr/bin/{binary_name}",
f"/usr/local/bin/{binary_name}",
f"/opt/homebrew/bin/{binary_name}",
f"/usr/lib/postgresql/16/bin/{binary_name}",
f"/usr/lib/postgresql/15/bin/{binary_name}",
f"/usr/lib/postgresql/14/bin/{binary_name}",
]
for candidate in candidates:
if Path(candidate).exists():
return candidate
raise FileNotFoundError(
f"{binary_name} blev ikke fundet i PATH. Installer postgresql-client eller rebuild API image."
)
async def create_database_backup(self, is_monthly: bool = False) -> Optional[int]:
"""
Create PostgreSQL database backup using pg_dump
@ -107,8 +83,6 @@ class BackupService:
job_id, backup_format, is_monthly)
try:
pg_dump_bin = self._resolve_pg_binary('pg_dump')
# Build pg_dump command - connect via network to postgres service
env = os.environ.copy()
env['PGPASSWORD'] = settings.DATABASE_URL.split(':')[2].split('@')[0] # Extract password
@ -128,10 +102,10 @@ class BackupService:
if backup_format == 'dump':
# Compressed custom format (-Fc)
cmd = [pg_dump_bin, '-h', host, '-U', user, '-Fc', dbname]
cmd = ['pg_dump', '-h', host, '-U', user, '-Fc', dbname]
else:
# Plain SQL format
cmd = [pg_dump_bin, '-h', host, '-U', user, dbname]
cmd = ['pg_dump', '-h', host, '-U', user, dbname]
# Execute pg_dump and write to file
logger.info("📦 Executing: %s > %s", ' '.join(cmd), backup_path)
@ -180,21 +154,6 @@ class BackupService:
backup_path.unlink()
return None
except FileNotFoundError as e:
error_msg = str(e)
logger.error("❌ Database backup failed: %s", error_msg)
execute_update(
"""UPDATE backup_jobs
SET status = %s, completed_at = %s, error_message = %s
WHERE id = %s""",
('failed', datetime.now(), error_msg, job_id)
)
if backup_path.exists():
backup_path.unlink()
return None
async def create_files_backup(self) -> Optional[int]:
"""
@ -453,12 +412,9 @@ class BackupService:
env['PGPASSWORD'] = password
psql_bin = self._resolve_pg_binary('psql')
pg_restore_bin = self._resolve_pg_binary('pg_restore')
# Step 1: Create new empty database
logger.info("📦 Creating new database: %s", new_dbname)
create_cmd = [psql_bin, '-h', host, '-U', user, '-d', 'postgres', '-c',
create_cmd = ['psql', '-h', host, '-U', user, '-d', 'postgres', '-c',
f"CREATE DATABASE {new_dbname} OWNER {user};"]
result = subprocess.run(create_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, env=env)
@ -474,7 +430,7 @@ class BackupService:
# Build restore command based on format
if backup['backup_format'] == 'dump':
# Restore from compressed custom format
cmd = [pg_restore_bin, '-h', host, '-U', user, '-d', new_dbname]
cmd = ['pg_restore', '-h', host, '-U', user, '-d', new_dbname]
logger.info("📥 Restoring to %s: %s < %s", new_dbname, ' '.join(cmd), backup_path)
@ -505,7 +461,7 @@ class BackupService:
if has_real_errors and not is_harmless:
logger.error("❌ pg_restore had REAL errors: %s", result.stderr[:1000])
# Try to drop the failed database
subprocess.run([psql_bin, '-h', host, '-U', user, '-d', 'postgres', '-c',
subprocess.run(['psql', '-h', host, '-U', user, '-d', 'postgres', '-c',
f"DROP DATABASE IF EXISTS {new_dbname};"], env=env)
raise RuntimeError(f"pg_restore failed with errors")
else:
@ -513,7 +469,7 @@ class BackupService:
else:
# Restore from plain SQL
cmd = [psql_bin, '-h', host, '-U', user, '-d', new_dbname]
cmd = ['psql', '-h', host, '-U', user, '-d', new_dbname]
logger.info("📥 Executing: %s < %s", ' '.join(cmd), backup_path)

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -131,7 +131,7 @@ async def get_contacts(
query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at,
c.title, c.department, c.is_active, c.created_at, c.updated_at,
COUNT(DISTINCT cc.customer_id) as company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c
@ -139,8 +139,8 @@ async def get_contacts(
LEFT JOIN customers cu ON cc.customer_id = cu.id
{where_sql}
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
ORDER BY company_count DESC, c.last_name, c.first_name, c.id
c.title, c.department, c.is_active, c.created_at, c.updated_at
ORDER BY company_count DESC, c.last_name, c.first_name
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
@ -325,114 +325,6 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/contacts/admin/backfill-company-links")
async def backfill_contact_company_links(dry_run: bool = Query(default=True)):
"""
Backfill missing contact_companies links by matching contacts.user_company to customers.name.
- Uses case-insensitive trimmed exact name matching
- Picks lowest customer ID if duplicate customer names exist
- Idempotent: will not create duplicate links
"""
try:
# Contacts that have a company name on the contact row.
contacts_with_company = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM contacts c
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
"""
)
# Contacts where the company name can be matched to a customer record.
matchable = execute_query_single(
"""
WITH company_match AS (
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
FROM customers
GROUP BY LOWER(TRIM(name))
)
SELECT COUNT(DISTINCT c.id)::int AS count
FROM contacts c
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
"""
)
# Contacts with no links at all (often the primary symptom).
unlinked = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM contacts c
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
AND NOT EXISTS (
SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id
)
"""
)
if dry_run:
return {
"dry_run": True,
"contacts_with_user_company": (contacts_with_company or {}).get("count", 0),
"matchable_contacts": (matchable or {}).get("count", 0),
"unlinked_contacts": (unlinked or {}).get("count", 0),
"message": "Dry run complete. Re-run with dry_run=false to insert links.",
}
inserted = execute_query(
"""
WITH company_match AS (
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
FROM customers
GROUP BY LOWER(TRIM(name))
),
candidates AS (
SELECT
c.id AS contact_id,
cm.customer_id,
CASE
WHEN EXISTS (
SELECT 1 FROM contact_companies cc1
WHERE cc1.contact_id = c.id
) THEN FALSE
ELSE TRUE
END AS is_primary
FROM contacts c
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
WHERE c.user_company IS NOT NULL
AND TRIM(c.user_company) <> ''
)
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
SELECT contact_id, customer_id, is_primary, 'inferred_user_company'
FROM candidates c
WHERE NOT EXISTS (
SELECT 1
FROM contact_companies cc
WHERE cc.contact_id = c.contact_id
AND cc.customer_id = c.customer_id
)
RETURNING contact_id, customer_id, is_primary
"""
)
inserted_count = len(inserted or [])
logger.info("✅ Contact-company backfill inserted %s link(s)", inserted_count)
return {
"dry_run": False,
"inserted": inserted_count,
"sample": (inserted or [])[:20],
"message": "Backfill completed",
}
except Exception as e:
logger.error("Failed backfill_contact_company_links: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/related-contacts")
async def get_related_contacts(contact_id: int):
"""Get contacts from the same companies as the contact (excluding itself)."""

File diff suppressed because it is too large Load Diff

View File

@ -31,11 +31,6 @@ class Settings(BaseSettings):
APIGW_TOKEN: str = ""
APIGW_TIMEOUT_SECONDS: int = 12
# FirmaAPI (CVR company data)
FIRMAAPI_BASE_URL: str = "https://firmaapi.dk/api/v1"
FIRMAAPI_API_KEY: str = ""
FIRMAAPI_TIMEOUT_SECONDS: int = 12
# Security
SECRET_KEY: str = "dev-secret-key-change-in-production"
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
@ -75,18 +70,6 @@ class Settings(BaseSettings):
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
NEXTCLOUD_ENCRYPTION_KEY: str = ""
# Links / Endpoints Module
LINKS_MODULE_ENABLED: bool = False
LINKS_READ_ONLY: bool = True
LINKS_DRY_RUN: bool = True
LINKS_DEAD_LINK_CHECK_ENABLED: bool = True
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60
LINKS_CHECK_TIMEOUT_SECONDS: int = 5
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL: str = ""
VAULTWARDEN_API_TOKEN: str = ""
# Wiki.js Integration
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
WIKI_API_TOKEN: str = ""
@ -123,7 +106,6 @@ class Settings(BaseSettings):
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL: bool = False
EMAIL_MAX_FETCH_PER_RUN: int = 50
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
EMAIL_WORKFLOWS_ENABLED: bool = True
@ -161,11 +143,6 @@ class Settings(BaseSettings):
TIMETRACKING_ROUND_INCREMENT: float = 0.5
TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
# Customer economic defaults
CUSTOMER_DEFAULT_MARGIN_PERCENT: float = 20.0
CUSTOMER_DEFAULT_INVOICE_FEE: float = 49.0
CUSTOMER_DEFAULT_HOURLY_RATE: float = 1200.0
# Time Tracking Module Safety Flags
TIMETRACKING_VTIGER_READ_ONLY: bool = True
TIMETRACKING_VTIGER_DRY_RUN: bool = True
@ -214,15 +191,6 @@ class Settings(BaseSettings):
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
UPLOAD_DIR: str = "uploads" # Upload directory path
# Bug report capture
BUG_REPORT_ENABLED: bool = True
BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B"
BUG_REPORT_MAX_PER_HOUR: int = 12
BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1
BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1
BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024
BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024
# Offsite Backup Settings (SFTP)
OFFSITE_ENABLED: bool = False
OFFSITE_WEEKLY_DAY: str = "sunday"
@ -259,19 +227,13 @@ class Settings(BaseSettings):
REMINDERS_QUEUE_BATCH_SIZE: int = 10
# AnyDesk Remote Support Integration
ANYDESK_API_URL: str = "https://v1.api.anydesk.com:8081" # AnyDesk REST API base URL
ANYDESK_LICENSE_ID: str = ""
ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com
ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN
ANYDESK_API_TOKEN: str = ""
ANYDESK_PASSWORD: str = ""
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
ANYDESK_LOCAL_SESSIONS_URL: str = "http://localhost:8001/anydesk/sessions"
ANYDESK_LOCAL_SYNC_ENABLED: bool = True
ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES: int = 15
ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS: int = 20
ANYDESK_LOCAL_SYNC_DRY_RUN: bool = False
# Telefoni (Yealink) Integration
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
@ -302,19 +264,6 @@ class Settings(BaseSettings):
SMS_SENDER: str = "BMC Networks"
SMS_WEBHOOK_SECRET: str = ""
# FedEx Integration
FEDEX_ENABLED: bool = False
FEDEX_READ_ONLY: bool = True
FEDEX_DRY_RUN: bool = True
FEDEX_API_KEY: str = ""
FEDEX_API_SECRET: str = ""
FEDEX_ACCOUNT_NUMBER: str = ""
FEDEX_BASE_URL: str = ""
FEDEX_TIMEOUT_SECONDS: int = 20
# Bottom bar module
BOTTOM_BAR_ENABLED: bool = False
# Dev-only shortcuts
DEV_ALLOW_ARCHIVED_IMPORT: bool = False

View File

@ -12,7 +12,7 @@ import asyncio
import aiohttp
from urllib.parse import quote
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
from app.core.database import execute_query, execute_query_single, execute_update
from app.core.config import settings
from app.services.cvr_service import get_cvr_service
from app.services.customer_activity_logger import CustomerActivityLogger
@ -23,42 +23,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _ensure_customer_supplier_tag(customer_id: int) -> None:
"""Ensure linked customers are tagged as suppliers."""
try:
tag = execute_query_single(
"SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1"
)
if tag and tag.get("id") is not None:
tag_id = int(tag["id"])
else:
created = execute_query_single(
"""
INSERT INTO tags (name, type, description, color, is_active)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, type)
DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
RETURNING id
""",
("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True),
)
tag_id = int(created["id"]) if created and created.get("id") is not None else None
if not tag_id:
return
execute_query(
"""
INSERT INTO entity_tags (entity_type, entity_id, tag_id)
VALUES (%s, %s, %s)
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
""",
("customer", customer_id, tag_id),
)
except Exception as tag_error:
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
# Pydantic Models
class CustomerBase(BaseModel):
name: str
@ -117,8 +81,7 @@ async def list_customers(
offset: int = Query(default=0, ge=0),
search: Optional[str] = Query(default=None),
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
is_active: Optional[bool] = Query(default=None),
vip: Optional[bool] = Query(default=None)
is_active: Optional[bool] = Query(default=None)
):
"""
List customers with pagination and filtering
@ -175,19 +138,6 @@ async def list_customers(
query += " AND c.is_active = %s"
params.append(is_active)
# Add VIP filter (customer tagged with "vip")
if vip is True:
query += """
AND EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'customer'
AND et.entity_id = c.id
AND LOWER(t.name) = 'vip'
)
"""
query += """
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
ORDER BY c.name
@ -220,18 +170,6 @@ async def list_customers(
count_query += " AND is_active = %s"
count_params.append(is_active)
if vip is True:
count_query += """
AND EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'customer'
AND et.entity_id = customers.id
AND LOWER(t.name) = 'vip'
)
"""
count_result = execute_query_single(count_query, tuple(count_params))
total = count_result['total'] if count_result else 0
@ -553,78 +491,6 @@ async def get_customer_utility_company(customer_id: int):
"supplier": supplier
}
@router.get("/customers/{customer_id}/vendors")
async def list_customer_vendors(customer_id: int):
"""List vendors linked to a customer."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
rows = execute_query(
"""
SELECT
l.id,
l.customer_id,
l.vendor_id,
l.relationship_type,
l.created_at,
l.updated_at,
v.name AS vendor_name,
v.email AS vendor_email,
v.cvr_number AS vendor_cvr
FROM customer_vendor_links l
JOIN vendors v ON v.id = l.vendor_id
WHERE l.customer_id = %s
ORDER BY v.name ASC, l.id ASC
""",
(customer_id,),
) or []
return rows
@router.post("/customers/{customer_id}/vendors/{vendor_id}")
async def link_customer_to_vendor(customer_id: int, vendor_id: int, relationship_type: str = Query("supplier")):
"""Create or update a customer-vendor link."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
rel = str(relationship_type or "supplier").strip().lower()
if rel not in {"supplier", "reseller", "partner"}:
raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
row = execute_query_single(
"""
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
VALUES (%s, %s, %s)
ON CONFLICT (customer_id, vendor_id)
DO UPDATE SET
relationship_type = EXCLUDED.relationship_type,
updated_at = CURRENT_TIMESTAMP
RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
""",
(customer_id, vendor_id, rel),
)
_ensure_customer_supplier_tag(int(customer_id))
return row
@router.delete("/customers/{customer_id}/vendors/{vendor_id}")
async def unlink_customer_from_vendor(customer_id: int, vendor_id: int):
"""Remove customer-vendor link."""
deleted = execute_update(
"DELETE FROM customer_vendor_links WHERE customer_id = %s AND vendor_id = %s",
(customer_id, vendor_id),
)
if not deleted:
raise HTTPException(status_code=404, detail="Link not found")
return {"success": True, "customer_id": customer_id, "vendor_id": vendor_id}
@router.post("/customers")
async def create_customer(customer: CustomerCreate):
"""Create a new customer"""
@ -1204,69 +1070,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
raise HTTPException(status_code=404, detail="Customer not found")
try:
normalized_email = (contact.email or "").strip().lower() or None
existing_contact = None
# Prefer exact email match scoped to this customer, then global email match.
if normalized_email:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND LOWER(COALESCE(c.email, '')) = %s
ORDER BY c.id ASC
LIMIT 1
""",
(customer_id, normalized_email),
)
if not existing_contact:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
WHERE LOWER(COALESCE(c.email, '')) = %s
ORDER BY c.id ASC
LIMIT 1
""",
(normalized_email,),
)
# Fallback dedupe by full name within same customer when email is missing.
if not existing_contact and not normalized_email:
existing_contact = execute_query_single(
"""
SELECT c.*
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s)
AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s)
ORDER BY c.id ASC
LIMIT 1
""",
(customer_id, contact.first_name, contact.last_name),
)
if existing_contact:
contact_id = int(existing_contact["id"])
execute_update(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (contact_id, customer_id)
DO UPDATE SET
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
role = COALESCE(contact_companies.role, EXCLUDED.role)
""",
(contact_id, customer_id, bool(contact.is_primary), contact.role),
)
logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id)
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
# Create contact when no reusable match exists.
# Create contact
contact_id = execute_insert(
"""INSERT INTO contacts
(first_name, last_name, email, phone, mobile, title, department)
@ -1275,7 +1079,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
(
contact.first_name,
contact.last_name,
normalized_email,
contact.email,
contact.phone,
contact.mobile,
contact.title,
@ -1284,12 +1088,11 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
)
# Link contact to customer
execute_update(
execute_insert(
"""INSERT INTO contact_companies
(contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, customer_id, bool(contact.is_primary), contact.role)
VALUES (%s, %s, %s, %s)""",
(contact_id, customer_id, contact.is_primary, contact.role)
)
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")

View File

@ -1,7 +1,6 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from app.core.config import settings
router = APIRouter()
templates = Jinja2Templates(directory="app")
@ -21,10 +20,7 @@ async def customer_detail_page(request: Request, customer_id: int):
"""
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
"request": request,
"customer_id": customer_id,
"customer_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,
"customer_id": customer_id
})

View File

@ -245,9 +245,6 @@
</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-light btn-sm" href="/links?customer_id={{ customer_id }}" title="Se links/endpoints for denne kunde">
<i class="bi bi-link-45deg me-2"></i>Links
</a>
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
</button>
@ -312,11 +309,6 @@
<i class="bi bi-people"></i>Kontakter
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#cases">
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt
@ -352,11 +344,6 @@
<i class="bi bi-hdd"></i>Hardware
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#links">
<i class="bi bi-link-45deg"></i>Links
</a>
</li>
<li class="nav-item d-none" id="nextcloudTabNav">
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
<i class="bi bi-cloud"></i>Nextcloud
@ -443,26 +430,6 @@
<span class="info-label">EAN-nummer</span>
<span class="info-value" id="ean">-</span>
</div>
<div class="info-row">
<span class="info-label">Standard avance</span>
<span class="info-value" id="standardMarginPercent">-</span>
</div>
<div class="info-row">
<span class="info-label">Standard timepris</span>
<span class="info-value" id="standardHourlyRate">-</span>
</div>
<div class="info-row">
<span class="info-label">Særlig fragtpris</span>
<span class="info-value" id="specialFreightPrice">-</span>
</div>
<div class="info-row">
<span class="info-label">Leverandørservice</span>
<span class="info-value" id="supplierServiceEnrolled">-</span>
</div>
<div class="info-row">
<span class="info-label">Faktureringsgebyr</span>
<span class="info-value" id="invoiceFeeAmount">-</span>
</div>
<div class="info-row">
<span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span>
@ -518,32 +485,6 @@
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
</div>
</div>
<div class="col-12">
<div class="info-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0">Leverandørrelationer</h5>
<span class="badge bg-primary" id="customerVendorsCount">0</span>
</div>
<div class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
class="form-control"
id="customerVendorSearch"
placeholder="Søg leverandør (navn, CVR, domæne)"
oninput="searchVendorsForCustomer(this.value)"
>
</div>
<div class="col-md-4 text-md-end">
<small class="text-muted">Knyt kunde til leverandør</small>
</div>
</div>
<div id="customerVendorSearchResults" class="list-group mb-3" style="display:none;"></div>
<div id="customerVendorLinksContainer" class="list-group mb-2"></div>
<div id="customerVendorLinksEmpty" class="text-muted small">Ingen linked leverandører endnu.</div>
</div>
</div>
</div>
</div>
@ -578,48 +519,6 @@
</div>
</div>
<!-- Cases Tab -->
<div class="tab-pane fade" id="cases">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kundens sager</h5>
<small class="text-muted">Alle sager knyttet til denne kunde</small>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-primary" href="/sag/new?customer_id={{ customer_id }}">
<i class="bi bi-plus-lg me-2"></i>Opret sag
</a>
<a class="btn btn-sm btn-outline-secondary" href="/sag?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn i sagsmodul
</a>
</div>
</div>
<div class="table-responsive" id="customerCasesContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerCasesEmpty" class="text-center py-5 text-muted d-none">
Ingen sager fundet for denne kunde
</div>
</div>
<!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4">
@ -849,42 +748,6 @@
</div>
</div>
<!-- Links Tab -->
<div class="tab-pane fade" id="links">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Links / Endpoints</h5>
<small class="text-muted">Driftslinks knyttet til denne kunde</small>
</div>
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn fuld visning
</a>
</div>
<div class="table-responsive" id="customerLinksContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerLinksEmpty" class="text-center py-5 text-muted d-none">
Ingen links fundet for denne kunde
</div>
</div>
<!-- Nextcloud Tab -->
<div class="tab-pane fade d-none" id="nextcloud">
{% include "modules/nextcloud/templates/tab.html" %}
@ -1043,43 +906,6 @@
<input type="text" class="form-control" id="editCity">
</div>
<!-- Economic defaults -->
<div class="col-12 mt-4">
<h6 class="text-muted text-uppercase small fw-bold mb-3">
<i class="bi bi-currency-exchange me-2"></i>Økonomiske standarder
</h6>
</div>
<div class="col-md-6">
<label for="editStandardMarginPercent" class="form-label">Standard avance (%)</label>
<input type="number" class="form-control" id="editStandardMarginPercent" min="0" max="1000" step="0.01">
</div>
<div class="col-md-6">
<label for="editStandardHourlyRate" class="form-label">Standard timepris (DKK)</label>
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
</div>
<div class="col-md-6">
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
</div>
<div class="col-md-6">
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
<label class="form-check-label" for="editSupplierServiceEnrolled">
Tilmeldt leverandørservice
</label>
</div>
</div>
<!-- Status -->
<div class="col-12 mt-4">
<div class="form-check form-switch">
@ -1376,9 +1202,6 @@
<script>
const customerId = parseInt(window.location.pathname.split('/').pop());
const customerDefaultMarginPercent = Number({{ customer_default_margin_percent | tojson }} || 20);
const customerDefaultInvoiceFee = Number({{ customer_default_invoice_fee | tojson }} || 49);
const customerDefaultHourlyRate = Number({{ customer_default_hourly_rate | tojson }} || 1200);
let customerData = null;
let pipelineStages = [];
let allTagsCache = [];
@ -1387,11 +1210,6 @@ let customerKontaktFilter = 'all';
let eventListenersAdded = false;
function getAuthHeaders() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
document.addEventListener('DOMContentLoaded', () => {
if (eventListenersAdded) {
console.log('Event listeners already added, skipping...');
@ -1408,13 +1226,6 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const casesTab = document.querySelector('a[href="#cases"]');
if (casesTab) {
casesTab.addEventListener('shown.bs.tab', () => {
loadCustomerCases();
}, { once: false });
}
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
@ -1455,13 +1266,6 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const linksTab = document.querySelector('a[href="#links"]');
if (linksTab) {
linksTab.addEventListener('shown.bs.tab', () => {
loadCustomerLinks();
}, { once: false });
}
// Load activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]');
if (activityTab) {
@ -1509,7 +1313,6 @@ async function loadCustomer() {
await loadUtilityCompany();
await loadCustomerTags();
await loadCustomerVendorLinks();
// Check data consistency
await checkDataConsistency();
@ -1520,141 +1323,6 @@ async function loadCustomer() {
}
}
async function loadCustomerVendorLinks() {
const container = document.getElementById('customerVendorLinksContainer');
const empty = document.getElementById('customerVendorLinksEmpty');
const countEl = document.getElementById('customerVendorsCount');
if (!container || !empty || !countEl) return;
try {
const response = await fetch(`/api/v1/customers/${customerId}/vendors`);
if (!response.ok) throw new Error('Kunne ikke hente leverandørlinks');
const links = await response.json();
const rows = Array.isArray(links) ? links : [];
countEl.textContent = String(rows.length);
if (!rows.length) {
container.innerHTML = '';
empty.classList.remove('d-none');
return;
}
empty.classList.add('d-none');
container.innerHTML = rows.map((row) => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}</div>
<div class="small text-muted">
${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''}
${row.vendor_email ? escapeHtml(row.vendor_email) : '-'}
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
<a class="btn btn-sm btn-outline-primary" href="/vendors/${row.vendor_id}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="unlinkVendorFromCustomer(${row.vendor_id})">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load customer vendor links:', error);
container.innerHTML = '';
empty.classList.remove('d-none');
}
}
let vendorSearchDebounce = null;
async function searchVendorsForCustomer(query) {
const resultsEl = document.getElementById('customerVendorSearchResults');
if (!resultsEl) return;
const q = String(query || '').trim();
if (!q) {
resultsEl.style.display = 'none';
resultsEl.innerHTML = '';
return;
}
if (vendorSearchDebounce) window.clearTimeout(vendorSearchDebounce);
vendorSearchDebounce = window.setTimeout(async () => {
try {
const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(q)}&limit=10`);
if (!response.ok) throw new Error('Søgning fejlede');
const vendors = await response.json();
const rows = Array.isArray(vendors) ? vendors : [];
if (!rows.length) {
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen leverandører fundet</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = rows.map((v) => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(v.name || '')}</div>
<div class="small text-muted">${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}</div>
</div>
<button class="btn btn-sm btn-primary" onclick="linkVendorToCustomerFromUI(${v.id})">
<i class="bi bi-link-45deg me-1"></i>Link
</button>
</div>
`).join('');
resultsEl.style.display = 'block';
} catch (error) {
console.error('Vendor search failed:', error);
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
resultsEl.style.display = 'block';
}
}, 220);
}
async function linkVendorToCustomerFromUI(vendorId) {
try {
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}?relationship_type=supplier`, {
method: 'POST'
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke linke leverandør');
}
const input = document.getElementById('customerVendorSearch');
const results = document.getElementById('customerVendorSearchResults');
if (input) input.value = '';
if (results) {
results.innerHTML = '';
results.style.display = 'none';
}
await loadCustomerVendorLinks();
await loadCustomerTags();
} catch (error) {
alert(error.message || 'Kunne ikke linke leverandør');
}
}
async function unlinkVendorFromCustomer(vendorId) {
if (!confirm('Fjern link mellem kunde og leverandør?')) return;
try {
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}`, {
method: 'DELETE'
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke fjerne link');
}
await loadCustomerVendorLinks();
} catch (error) {
alert(error.message || 'Kunne ikke fjerne link');
}
}
function displayCustomer(customer) {
// Update page title
document.title = `${customer.name} - BMC Hub`;
@ -1734,22 +1402,6 @@ function displayCustomer(customer) {
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
document.getElementById('ean').textContent = customer.ean || '-';
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
const freight = customer.special_freight_price;
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
? '-'
: `${Number(freight).toFixed(2)} DKK`;
document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
? '<span class="badge bg-success">Tilmeldt</span>'
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
? '0,00 DKK (deaktiveret)'
: `${Number(invoiceFee).toFixed(2)} DKK`;
document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>';
@ -2663,107 +2315,6 @@ async function loadContacts() {
}
}
async function loadCustomerCases() {
const container = document.getElementById('customerCasesContainer');
const empty = document.getElementById('customerCasesEmpty');
if (!container || !empty) {
return;
}
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
`;
try {
const response = await fetch(`/api/v1/sag?customer_id=${customerId}`);
const cases = await response.json();
if (!response.ok) {
throw new Error(cases?.detail || 'Kunne ikke hente kundens sager');
}
const list = Array.isArray(cases) ? cases : [];
if (!list.length) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
const rows = list.map((item) => {
const id = Number(item.id) || 0;
const title = escapeHtml(item.titel || '-');
const statusRaw = String(item.status || 'ukendt');
const statusLabel = escapeHtml(statusRaw);
const priority = escapeHtml(item.priority || 'normal');
const created = item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '-';
const statusClass =
statusRaw.toLowerCase() === 'lukket' ? 'bg-success-subtle text-success-emphasis' :
statusRaw.toLowerCase() === 'afventer' ? 'bg-warning-subtle text-warning-emphasis' :
'bg-primary-subtle text-primary-emphasis';
return `
<tr>
<td><a href="/sag/${id}/v3" class="fw-semibold text-decoration-none">#${id}</a></td>
<td>${title}</td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
<td><span class="badge bg-light text-dark border">${priority}</span></td>
<td>${created}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/sag/${id}/v3" title="Åbn sag">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
} catch (error) {
console.error('Failed to load customer cases:', error);
container.innerHTML = `<div class="alert alert-danger mb-0"><i class="bi bi-exclamation-circle me-2"></i>${escapeHtml(error.message || 'Fejl ved hentning af sager')}</div>`;
}
}
let subscriptionsLoaded = false;
async function loadSubscriptions() {
@ -2825,7 +2376,6 @@ async function loadCustomerPipeline() {
let customerHardware = [];
let hardwareLocationsById = {};
let customerLinks = [];
function getHardwareGroupLabel(item, groupBy) {
if (groupBy === 'location') {
@ -2998,109 +2548,6 @@ document.addEventListener('change', (event) => {
}
});
function renderCustomerLinksTable() {
const container = document.getElementById('customerLinksContainer');
const empty = document.getElementById('customerLinksEmpty');
if (!container || !empty) return;
if (!customerLinks.length) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
${customerLinks.map((link) => {
const type = (link.type || 'http').toUpperCase();
const target = link.url || link.host || '-';
const environment = link.environment || 'prod';
return `
<tr>
<td class="fw-semibold">${escapeHtml(link.name || 'Uden navn')}</td>
<td><span class="badge text-bg-secondary">${escapeHtml(type)}</span></td>
<td>${escapeHtml(target)}</td>
<td>${escapeHtml(environment)}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id=${customerId}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
async function loadCustomerLinks() {
const container = document.getElementById('customerLinksContainer');
const empty = document.getElementById('customerLinksEmpty');
if (!container || !empty) return;
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center py-4"><div class="spinner-border text-primary"></div></td>
</tr>
</tbody>
</table>
`;
try {
const response = await fetch(`/api/v1/links?customer_id=${customerId}`, {
headers: {
...getAuthHeaders()
},
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Ingen adgang til links. Log ind igen eller tjek links.read permission.');
}
if (response.status === 404) {
throw new Error('Links-endpoint ikke fundet (modul ikke aktivt eller API ikke genstartet).');
}
throw new Error('Kunne ikke hente links');
}
const links = await response.json();
customerLinks = Array.isArray(links) ? links : [];
renderCustomerLinksTable();
} catch (error) {
console.error('Failed to load customer links:', error);
container.classList.add('d-none');
empty.classList.remove('d-none');
empty.textContent = error.message || 'Kunne ikke hente links for kunden';
}
}
function renderCustomerPipeline(opportunities) {
const tbody = document.getElementById('customerOpportunitiesTable');
if (!opportunities || opportunities.length === 0) {
@ -3975,11 +3422,6 @@ function editCustomer() {
document.getElementById('editAddress').value = customerData.address || '';
document.getElementById('editPostalCode').value = customerData.postal_code || '';
document.getElementById('editCity').value = customerData.city || '';
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
document.getElementById('editIsActive').checked = customerData.is_active !== false;
// Show modal
@ -3988,11 +3430,6 @@ function editCustomer() {
}
async function saveCustomerEdit() {
const marginValue = document.getElementById('editStandardMarginPercent').value;
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
const freightValue = document.getElementById('editSpecialFreightPrice').value;
const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
const updateData = {
name: document.getElementById('editName').value,
cvr_number: document.getElementById('editCvrNumber').value || null,
@ -4006,11 +3443,6 @@ async function saveCustomerEdit() {
address: document.getElementById('editAddress').value || null,
postal_code: document.getElementById('editPostalCode').value || null,
city: document.getElementById('editCity').value || null,
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue),
special_freight_price: freightValue === '' ? null : Number(freightValue),
supplier_service_enrolled: document.getElementById('editSupplierServiceEnrolled').checked,
invoice_fee_amount: invoiceFeeValue === '' ? customerDefaultInvoiceFee : Number(invoiceFeeValue),
is_active: document.getElementById('editIsActive').checked
};

View File

@ -4,53 +4,6 @@
{% block extra_css %}
<style>
.customers-toolbar {
gap: 1rem;
}
.toolbar-search-slot {
flex: 1;
display: flex;
justify-content: center;
}
.search-wrap {
position: relative;
min-width: 280px;
max-width: 460px;
width: min(46vw, 460px);
}
.search-wrap .header-search {
width: 100%;
padding-right: 2.4rem;
}
.search-clear {
position: absolute;
right: 0.45rem;
top: 50%;
transform: translateY(-50%);
border: 0;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
background: transparent;
}
.search-clear:hover {
background: rgba(15, 76, 117, 0.12);
color: var(--text-primary);
}
.search-clear.d-none {
display: none !important;
}
.filter-btn {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
@ -66,56 +19,26 @@
color: white;
border-color: var(--accent);
}
.lookup-status {
font-size: 0.85rem;
color: var(--text-secondary);
}
@media (max-width: 992px) {
.customers-toolbar {
width: 100%;
flex-direction: column;
align-items: stretch !important;
}
.toolbar-search-slot {
width: 100%;
justify-content: stretch;
}
.search-wrap {
width: 100%;
max-width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5 customers-toolbar">
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1">Kunder</h2>
<p class="text-muted mb-0">Administrer dine kunder</p>
</div>
<div class="toolbar-search-slot">
<div class="search-wrap">
<input type="search" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, kontakt eller e-mail..." autocomplete="off" spellcheck="false">
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="d-flex gap-3">
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde...">
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
</div>
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal">
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
</button>
</div>
<div class="mb-4 d-flex gap-2">
<button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button>
<button class="filter-btn" data-filter="active" type="button">Aktive</button>
<button class="filter-btn" data-filter="inactive" type="button">Inaktive</button>
<button class="filter-btn" data-filter="vip" type="button">VIP</button>
<button class="filter-btn active">Alle Kunder</button>
<button class="filter-btn">Aktive</button>
<button class="filter-btn">Inaktive</button>
<button class="filter-btn">VIP</button>
</div>
<div class="card p-4">
@ -150,391 +73,55 @@
</div>
</div>
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createCustomerModalLabel">Opret ny kunde</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<form id="createCustomerForm">
<div class="modal-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="createCustomerCvr">CVR</label>
<div class="input-group">
<input type="text" class="form-control" id="createCustomerCvr" placeholder="fx 24256790" inputmode="numeric" maxlength="8">
<button type="button" class="btn btn-outline-secondary" id="lookupCvrBtn">Hent</button>
</div>
<div class="lookup-status mt-1" id="lookupCvrStatus">Indtast CVR og klik Hent for autofyld.</div>
</div>
<div class="col-md-8">
<label class="form-label" for="createCustomerName">Virksomhedsnavn *</label>
<input type="text" class="form-control" id="createCustomerName" required>
</div>
<div class="col-md-6">
<label class="form-label" for="createCustomerEmail">E-mail</label>
<input type="email" class="form-control" id="createCustomerEmail">
</div>
<div class="col-md-6">
<label class="form-label" for="createCustomerInvoiceEmail">Faktura e-mail</label>
<input type="email" class="form-control" id="createCustomerInvoiceEmail">
</div>
<div class="col-md-6">
<label class="form-label" for="createCustomerPhone">Telefon</label>
<input type="text" class="form-control" id="createCustomerPhone">
</div>
<div class="col-md-6">
<label class="form-label" for="createCustomerWebsite">Website</label>
<input type="url" class="form-control" id="createCustomerWebsite" placeholder="https://...">
</div>
<div class="col-md-8">
<label class="form-label" for="createCustomerAddress">Adresse</label>
<input type="text" class="form-control" id="createCustomerAddress">
</div>
<div class="col-md-2">
<label class="form-label" for="createCustomerPostalCode">Postnr.</label>
<input type="text" class="form-control" id="createCustomerPostalCode">
</div>
<div class="col-md-2">
<label class="form-label" for="createCustomerCity">By</label>
<input type="text" class="form-control" id="createCustomerCity">
</div>
<div class="col-md-4">
<label class="form-label" for="createCustomerCountry">Land</label>
<input type="text" class="form-control" id="createCustomerCountry" value="DK">
</div>
<div class="col-md-8 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="createCustomerIsActive" checked>
<label class="form-check-label" for="createCustomerIsActive">Kunden er aktiv</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="submit" class="btn btn-primary" id="createCustomerSubmitBtn">
<span class="submit-label">Opret kunde</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script>
let currentPage = 1;
const pageSize = 50;
let totalCustomers = 0;
let searchTerm = '';
let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let createCustomerModal = null;
let activeFilter = 'all';
// Load customers on page load
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
// Setup search with debounce
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn');
const triggerSearch = () => {
const nextSearchTerm = searchInput.value.trim();
if (nextSearchTerm === searchTerm) {
toggleClearButton(nextSearchTerm);
return;
}
searchTerm = nextSearchTerm;
toggleClearButton(searchTerm);
loadCustomers(1);
};
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
toggleClearButton(e.target.value.trim());
searchTimeout = setTimeout(() => {
triggerSearch();
searchTerm = e.target.value;
loadCustomers(1);
}, 300);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(searchTimeout);
triggerSearch();
}
if (e.key === 'Escape') {
if (!searchInput.value) {
return;
}
searchInput.value = '';
clearTimeout(searchTimeout);
triggerSearch();
}
});
clearBtn.addEventListener('click', () => {
if (!searchInput.value) {
return;
}
searchInput.value = '';
clearTimeout(searchTimeout);
triggerSearch();
searchInput.focus();
});
document.getElementById('createCustomerForm').addEventListener('submit', createCustomer);
document.getElementById('lookupCvrBtn').addEventListener('click', lookupCvrAndAutofill);
document.getElementById('createCustomerCvr').addEventListener('input', onCvrInput);
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
const nextFilter = btn.dataset.filter || 'all';
if (nextFilter === activeFilter) {
return;
}
activeFilter = nextFilter;
syncFilterButtons();
lastLoadedQueryKey = '';
loadCustomers(1);
});
});
document.getElementById('createCustomerModal').addEventListener('hidden.bs.modal', () => {
resetCreateCustomerForm();
});
});
function onCvrInput(e) {
const digits = String(e.target.value || '').replace(/\D/g, '').slice(0, 8);
e.target.value = digits;
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
}
function setLookupStatus(message, isError = false) {
const status = document.getElementById('lookupCvrStatus');
status.textContent = message;
status.classList.toggle('text-danger', isError);
}
async function lookupCvrAndAutofill() {
const cvrInput = document.getElementById('createCustomerCvr');
const lookupBtn = document.getElementById('lookupCvrBtn');
const cvr = String(cvrInput.value || '').replace(/\D/g, '');
if (cvr.length !== 8) {
setLookupStatus('CVR skal være præcis 8 cifre.', true);
return;
}
lookupBtn.disabled = true;
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
setLookupStatus('Henter data fra FirmaAPI...', false);
try {
const response = await fetch(`/api/v1/cvr/${cvr}`);
if (!response.ok) {
if (response.status === 404) {
setLookupStatus('CVR blev ikke fundet.', true);
return;
}
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
applyCustomerAutofill(data || {});
setLookupStatus('CVR-data hentet og felter autofyldt.', false);
} catch (error) {
console.error('CVR lookup failed:', error);
setLookupStatus(`Kunne ikke hente CVR-data: ${error.message}`, true);
} finally {
lookupBtn.disabled = false;
lookupBtn.textContent = 'Hent';
}
}
function applyCustomerAutofill(data) {
if (data.name) document.getElementById('createCustomerName').value = data.name;
if (data.email) document.getElementById('createCustomerEmail').value = data.email;
if (data.phone) document.getElementById('createCustomerPhone').value = data.phone;
if (data.address) document.getElementById('createCustomerAddress').value = data.address;
if (data.city) document.getElementById('createCustomerCity').value = data.city;
if (data.postal_code || data.zipcode) {
document.getElementById('createCustomerPostalCode').value = data.postal_code || data.zipcode;
}
if (data.country) document.getElementById('createCustomerCountry').value = data.country;
if (data.website) document.getElementById('createCustomerWebsite').value = data.website;
}
function buildCreateCustomerPayload() {
const email = document.getElementById('createCustomerEmail').value.trim();
const domain = email.includes('@') ? email.split('@').pop().toLowerCase() : null;
const cleanValue = (id) => {
const value = document.getElementById(id).value.trim();
return value || null;
};
return {
name: document.getElementById('createCustomerName').value.trim(),
cvr_number: cleanValue('createCustomerCvr'),
email: email || null,
email_domain: domain,
phone: cleanValue('createCustomerPhone'),
address: cleanValue('createCustomerAddress'),
city: cleanValue('createCustomerCity'),
postal_code: cleanValue('createCustomerPostalCode'),
country: cleanValue('createCustomerCountry') || 'DK',
website: cleanValue('createCustomerWebsite'),
is_active: document.getElementById('createCustomerIsActive').checked,
invoice_email: cleanValue('createCustomerInvoiceEmail'),
mobile_phone: null,
};
}
async function createCustomer(event) {
event.preventDefault();
const submitBtn = document.getElementById('createCustomerSubmitBtn');
const submitLabel = submitBtn.querySelector('.submit-label');
const payload = buildCreateCustomerPayload();
if (!payload.name) {
setLookupStatus('Virksomhedsnavn er påkrævet.', true);
return;
}
submitBtn.disabled = true;
submitLabel.textContent = 'Opretter...';
try {
const response = await fetch('/api/v1/customers', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `HTTP ${response.status}`);
}
const created = await response.json();
createCustomerModal.hide();
searchTerm = '';
document.getElementById('searchInput').value = '';
toggleClearButton('');
lastLoadedQueryKey = '';
await loadCustomers(1);
if (created && created.id) {
window.location.href = `/customers/${created.id}`;
return;
}
} catch (error) {
console.error('Failed to create customer:', error);
setLookupStatus(`Oprettelse fejlede: ${error.message}`, true);
} finally {
submitBtn.disabled = false;
submitLabel.textContent = 'Opret kunde';
}
}
function resetCreateCustomerForm() {
const form = document.getElementById('createCustomerForm');
form.reset();
document.getElementById('createCustomerCountry').value = 'DK';
document.getElementById('createCustomerIsActive').checked = true;
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
}
function syncFilterButtons() {
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.filter === activeFilter);
});
}
async function loadCustomers(page = 1) {
currentPage = page;
const offset = (page - 1) * pageSize;
if (currentRequestController) {
currentRequestController.abort();
}
currentRequestController = new AbortController();
try {
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
if (activeFilter === 'active') {
url += '&is_active=true';
} else if (activeFilter === 'inactive') {
url += '&is_active=false';
} else if (activeFilter === 'vip') {
url += '&vip=true';
}
const queryKey = `${page}|${searchTerm}|${activeFilter}`;
if (queryKey === lastLoadedQueryKey) {
return;
}
const response = await fetch(url, { signal: currentRequestController.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const response = await fetch(url);
const data = await response.json();
lastLoadedQueryKey = queryKey;
totalCustomers = data.total;
renderCustomers(data.customers);
renderPagination();
updateCount();
} catch (error) {
if (error.name === 'AbortError') {
return;
}
console.error('Error loading customers:', error);
document.getElementById('customersTableBody').innerHTML = `
<tr><td colspan="6" class="text-center text-danger py-5">
❌ Fejl ved indlæsning: ${error.message}
</td></tr>
`;
} finally {
currentRequestController = null;
}
}
function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
}
function escapeHtml(value) {
if (value === null || value === undefined) {
return '-';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderCustomers(customers) {
const tbody = document.getElementById('customersTableBody');
@ -552,13 +139,6 @@ function renderCustomers(customers) {
const statusBadge = customer.is_active ?
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
const safeInitials = escapeHtml(initials);
const safeName = escapeHtml(customer.name);
const safeAddress = escapeHtml(customer.address);
const safeContactName = escapeHtml(customer.contact_name);
const safeContactPhone = escapeHtml(customer.contact_phone);
const safeCvr = escapeHtml(customer.cvr_number);
const safeEmail = escapeHtml(customer.email);
return `
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
@ -566,21 +146,21 @@ function renderCustomers(customers) {
<div class="d-flex align-items-center">
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
style="width: 40px; height: 40px; color: var(--accent);">
${safeInitials}
${initials}
</div>
<div>
<div class="fw-bold">${safeName}</div>
<div class="small text-muted">${safeAddress}</div>
<div class="fw-bold">${customer.name || '-'}</div>
<div class="small text-muted">${customer.address || '-'}</div>
</div>
</div>
</td>
<td>
<div class="fw-medium">${safeContactName}</div>
<div class="small text-muted">${safeContactPhone}</div>
<div class="fw-medium">${customer.contact_name || '-'}</div>
<div class="small text-muted">${customer.contact_phone || '-'}</div>
</td>
<td class="text-muted">${safeCvr}</td>
<td class="text-muted">${customer.cvr_number || '-'}</td>
<td>${statusBadge}</td>
<td class="text-muted">${safeEmail}</td>
<td class="text-muted">${customer.email || '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
@ -656,11 +236,6 @@ function renderPagination() {
}
function updateCount() {
if (totalCustomers === 0) {
document.getElementById('customerCount').textContent = 'Ingen kunder fundet';
return;
}
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalCustomers);
document.getElementById('customerCount').textContent =

View File

@ -289,7 +289,7 @@ async function createOpportunity() {
}
function goToDetail(id) {
window.location.href = `/sag/${id}/v3`;
window.location.href = `/sag/${id}`;
}
function formatCurrency(value, currency) {

View File

@ -326,217 +326,6 @@ class MissionService:
result.append(item)
return result
@staticmethod
def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]:
rows = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', u.user_id::text)) AS display_name
FROM users u
WHERE COALESCE(u.is_active, TRUE) = TRUE
ORDER BY display_name ASC
LIMIT %s
""",
(limit,),
) or []
return [
{
"user_id": int(row.get("user_id") or 0),
"display_name": row.get("display_name") or "Ukendt",
}
for row in rows
if row.get("user_id") is not None
]
@staticmethod
def get_assignment_groups(limit: int = 200) -> list[Dict[str, Any]]:
if not MissionService._table_exists("groups"):
return []
rows = execute_query(
"""
SELECT id, COALESCE(NULLIF(TRIM(name), ''), CONCAT('Gruppe #', id::text)) AS name
FROM groups
ORDER BY name ASC
LIMIT %s
""",
(limit,),
) or []
return [
{
"id": int(row.get("id") or 0),
"name": row.get("name") or "Ukendt gruppe",
}
for row in rows
if row.get("id") is not None
]
@staticmethod
def get_day_unassigned_cases(limit: int = 120) -> list[Dict[str, Any]]:
if not MissionService._table_exists("sag_sager"):
return []
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.status,
s.priority,
s.start_date,
s.deadline,
s.created_at,
s.ansvarlig_bruger_id,
s.assigned_group_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS ansvarlig_navn,
COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS assigned_group_name,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
AND (s.ansvarlig_bruger_id IS NULL OR s.assigned_group_id IS NULL)
ORDER BY
CASE
WHEN s.ansvarlig_bruger_id IS NULL AND s.assigned_group_id IS NULL THEN 0
ELSE 1
END ASC,
CASE LOWER(COALESCE(s.priority::text, ''))
WHEN 'kritisk' THEN 5
WHEN 'critical' THEN 5
WHEN 'høj' THEN 4
WHEN 'hoj' THEN 4
WHEN 'high' THEN 4
WHEN 'urgent' THEN 4
WHEN 'medium' THEN 3
WHEN 'normal' THEN 2
WHEN 'lav' THEN 1
WHEN 'low' THEN 1
ELSE 2
END DESC,
s.deadline ASC NULLS LAST,
s.created_at ASC
LIMIT %s
""",
(limit,),
) or []
return [dict(row) for row in rows]
@staticmethod
def get_day_agent_workloads(limit_agents: int = 60, limit_cases_per_agent: int = 20) -> list[Dict[str, Any]]:
if not MissionService._table_exists("sag_sager"):
return []
rows = execute_query(
"""
WITH active_cases AS (
SELECT
s.id,
s.titel,
s.status,
s.priority,
s.start_date,
s.deadline,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
s.ansvarlig_bruger_id,
s.assigned_group_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS assignee_name,
COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
AND (
(s.start_date IS NOT NULL AND s.start_date::date <= CURRENT_DATE)
OR
(s.deadline IS NOT NULL AND s.deadline::date <= CURRENT_DATE)
)
),
grouped AS (
SELECT
COALESCE(
CASE
WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text)
WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text)
ELSE 'unassigned'
END,
'unassigned'
) AS assignee_key,
COALESCE(assignee_name, group_name, 'Ufordelt') AS assignee_name,
COUNT(*) AS total_cases,
COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_cases,
COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS due_today_cases,
COUNT(*) FILTER (WHERE start_date IS NOT NULL AND start_date::date <= CURRENT_DATE) AS started_cases,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', id,
'titel', titel,
'status', status,
'priority', priority,
'customer_name', customer_name,
'start_date', start_date,
'deadline', deadline,
'created_at', created_at
)
ORDER BY
CASE
WHEN deadline IS NOT NULL AND deadline::date < CURRENT_DATE THEN 0
WHEN deadline IS NOT NULL AND deadline::date = CURRENT_DATE THEN 1
ELSE 2
END,
deadline ASC NULLS LAST,
created_at ASC
) AS case_list
FROM active_cases
GROUP BY assignee_key, assignee_name
)
SELECT
assignee_key,
assignee_name,
total_cases,
overdue_cases,
due_today_cases,
started_cases,
CASE
WHEN case_list IS NULL THEN '[]'::jsonb
ELSE case_list
END AS case_list
FROM grouped
ORDER BY overdue_cases DESC, due_today_cases DESC, total_cases DESC, assignee_name ASC
LIMIT %s
""",
(limit_agents,),
) or []
result: list[Dict[str, Any]] = []
for row in rows:
case_list = row.get("case_list")
if isinstance(case_list, list):
trimmed_cases = case_list[: max(1, int(limit_cases_per_agent))]
else:
trimmed_cases = []
result.append(
{
"assignee_key": row.get("assignee_key") or "unassigned",
"assignee_name": row.get("assignee_name") or "Ufordelt",
"total_cases": int(row.get("total_cases") or 0),
"overdue_cases": int(row.get("overdue_cases") or 0),
"due_today_cases": int(row.get("due_today_cases") or 0),
"started_cases": int(row.get("started_cases") or 0),
"case_list": trimmed_cases,
}
)
return result
@staticmethod
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
if not MissionService._table_exists("email_messages"):
@ -603,10 +392,6 @@ class MissionService:
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
"day_unassigned_cases": MissionService._safe("day_unassigned_cases", lambda: MissionService.get_day_unassigned_cases(120), []),
"day_agent_workloads": MissionService._safe("day_agent_workloads", lambda: MissionService.get_day_agent_workloads(60, 20), []),
"assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []),
"assignment_groups": MissionService._safe("assignment_groups", lambda: MissionService.get_assignment_groups(200), []),
"recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []),
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
"config": {

View File

@ -78,19 +78,9 @@
font-size: 0.85rem;
}
.mc-controls input[type="checkbox"] {
width: 18px;
height: 18px;
vertical-align: middle;
}
.mc-controls input[type="range"] {
min-width: 130px;
}
.mc-nav {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
}
@ -104,7 +94,6 @@
font-weight: 700;
letter-spacing: 0.01em;
transition: 0.15s ease;
touch-action: manipulation;
}
.mc-nav-btn.active {
@ -137,7 +126,6 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
touch-action: manipulation;
}
.mc-chip.active {
@ -401,7 +389,6 @@
font-size: 0.84rem;
font-weight: 700;
min-width: 54px;
touch-action: manipulation;
}
.mc-duration-btn.active {
@ -506,233 +493,6 @@
padding-bottom: 0;
}
.mc-day-tabs {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
margin-bottom: 0.72rem;
}
.mc-day-tab {
border: 1px solid var(--mc-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text-muted);
font-size: 0.82rem;
font-weight: 700;
padding: 0.3rem 0.78rem;
touch-action: manipulation;
}
.mc-day-tab.active {
color: #dff3ff;
border-color: #77b6df;
background: var(--mc-accent-soft);
}
.mc-day-pane {
display: none;
}
.mc-day-pane.active {
display: block;
}
.mc-day-case-card {
border: 1px solid rgba(157, 181, 210, 0.2);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.65rem 0.72rem;
margin-bottom: 0.55rem;
}
.mc-day-case-title {
font-weight: 800;
font-size: 0.95rem;
}
.mc-day-case-sub {
color: var(--mc-text-muted);
font-size: 0.78rem;
margin-top: 0.12rem;
}
.mc-day-case-desc {
margin-top: 0.4rem;
font-size: 0.85rem;
color: #d9ebff;
line-height: 1.25;
}
.mc-day-assign-row {
margin-top: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.45rem;
align-items: center;
}
.mc-day-select {
border: 1px solid var(--mc-border);
border-radius: 9px;
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text);
padding: 0.36rem 0.5rem;
font-size: 0.82rem;
}
.mc-day-btn {
border: 1px solid rgba(126, 194, 239, 0.45);
border-radius: 9px;
background: rgba(15, 76, 117, 0.45);
color: #dff3ff;
font-size: 0.81rem;
font-weight: 700;
padding: 0.35rem 0.62rem;
white-space: nowrap;
touch-action: manipulation;
}
@media (pointer: coarse) {
.mc-shell {
gap: 1rem;
}
.mc-card {
padding: 1rem 1.1rem;
}
.mc-title {
font-size: 1.8rem;
}
.mc-subtle {
font-size: 1rem;
}
.mc-controls {
gap: 1rem;
}
.mc-controls label {
font-size: 0.95rem;
}
.mc-controls input[type="checkbox"] {
width: 22px;
height: 22px;
}
.mc-controls input[type="range"] {
min-width: 180px;
height: 28px;
}
.mc-nav-btn {
min-height: 72px;
font-size: 1.14rem;
border-radius: 14px;
}
.mc-day-tab,
.mc-chip {
min-height: 44px;
font-size: 0.96rem;
padding: 0.5rem 1rem;
}
.mc-day-select {
min-height: 48px;
font-size: 0.95rem;
}
.mc-day-btn,
.mc-duration-btn {
min-height: 46px;
font-size: 0.95rem;
padding: 0.45rem 0.8rem;
}
.mc-case-title,
.mc-day-case-title {
font-size: 1.05rem;
}
.mc-case-sub,
.mc-day-case-sub,
.mc-feed-meta {
font-size: 0.9rem;
}
}
.mc-day-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mc-day-agents {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.mc-agent-card {
border: 1px solid rgba(157, 181, 210, 0.22);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.62rem 0.7rem;
}
.mc-agent-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
margin-bottom: 0.45rem;
}
.mc-agent-name {
font-size: 0.9rem;
font-weight: 800;
}
.mc-agent-metrics {
display: flex;
gap: 0.32rem;
flex-wrap: wrap;
margin-bottom: 0.45rem;
}
.mc-day-mini {
font-size: 0.72rem;
padding: 0.17rem 0.42rem;
}
.mc-day-mini.alert {
border-color: rgba(239, 68, 68, 0.5);
color: #ffd0d0;
}
.mc-day-mini.warn {
border-color: rgba(245, 158, 11, 0.5);
color: #ffdb9f;
}
.mc-day-agent-cases {
display: grid;
gap: 0.32rem;
max-height: 220px;
overflow: auto;
}
.mc-day-agent-case {
border-radius: 10px;
border: 1px solid rgba(157, 181, 210, 0.18);
background: rgba(255, 255, 255, 0.02);
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
}
.mc-feed-title {
font-weight: 700;
font-size: 0.9rem;
@ -787,10 +547,6 @@
.mc-row-head {
grid-template-columns: 1.7fr 1fr 1fr 1fr;
}
.mc-day-agents {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
@ -808,10 +564,6 @@
min-height: min(70vh, 720px);
}
.mc-day-assign-row {
grid-template-columns: 1fr;
}
.mc-email-row {
grid-template-columns: 1fr;
}
@ -847,13 +599,16 @@
<div class="mc-nav" id="missionNav">
<button class="mc-nav-btn active" type="button" data-view="overview">Overblik</button>
<button class="mc-nav-btn" type="button" data-view="day">Dagen</button>
<button class="mc-nav-btn" type="button" data-view="important">Vigtige sager</button>
<button class="mc-nav-btn" type="button" data-view="calls">Opkald</button>
<button class="mc-nav-btn" type="button" data-view="camera">Kamera</button>
</div>
</section>
<section class="mc-card">
<div class="mc-filter-row" id="caseFilterChips"></div>
</section>
<section>
<div id="view-overview" class="mc-view active">
<div class="mc-view-grid">
@ -903,26 +658,6 @@
</div>
</div>
<div id="view-day" class="mc-view">
<div class="mc-card">
<h4 class="mb-2">Dagen</h4>
<div class="mc-subtle mb-3">Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.</div>
<div class="mc-day-tabs" id="dayTabs">
<button type="button" class="mc-day-tab active" data-day-tab="new_cases">Nye sager</button>
<button type="button" class="mc-day-tab" data-day-tab="today">Idag</button>
</div>
<div id="dayPane-new_cases" class="mc-day-pane active">
<div id="dayUnassignedList"></div>
</div>
<div id="dayPane-today" class="mc-day-pane">
<div id="dayAgentsList" class="mc-day-agents"></div>
</div>
</div>
</div>
<div id="view-calls" class="mc-view">
<div class="mc-view-grid">
<div class="mc-card">
@ -960,6 +695,10 @@
</div>
</section>
<section class="mc-card">
<h5 class="mb-2">Live aktivitetsfeed</h5>
<div id="liveFeed" class="mc-feed"></div>
</section>
</div>
<script>
@ -991,7 +730,6 @@
idleTimeoutMs: 10000,
currentView: 'overview',
caseFilter: 'all',
dayTab: 'new_cases',
preSpotlightView: null,
cameraSpotlightTimer: null,
spotlightTargetId: null,
@ -1013,10 +751,6 @@
activeAlerts: [],
liveFeed: [],
importantCases: [],
dayUnassignedCases: [],
dayAgentWorkloads: [],
assignmentUsers: [],
assignmentGroups: [],
recentEmails: [],
environmentReadings: [],
cameraMotion: null,
@ -1052,21 +786,7 @@
function getCaseHref(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return '/sag';
return `/sag/${id}/v3`;
}
function toOptionalInt(value) {
const raw = String(value ?? '').trim();
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function truncateText(value, maxLength = 180) {
const raw = String(value || '').trim();
if (!raw) return '';
if (raw.length <= maxLength) return raw;
return `${raw.slice(0, maxLength - 1)}...`;
return `/sag/${id}`;
}
function getEmailHref(emailId) {
@ -1463,190 +1183,6 @@
`).join('');
}
function renderDayTabs() {
const host = document.getElementById('dayTabs');
if (!host) return;
host.querySelectorAll('.mc-day-tab').forEach((btn) => {
const key = btn.dataset.dayTab || 'new_cases';
btn.classList.toggle('active', key === state.dayTab);
});
document.querySelectorAll('.mc-day-pane').forEach((pane) => {
pane.classList.remove('active');
});
const activePane = document.getElementById(`dayPane-${state.dayTab}`);
if (activePane) {
activePane.classList.add('active');
}
}
function renderDayUnassignedCases() {
const list = document.getElementById('dayUnassignedList');
if (!list) return;
const rows = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
if (!rows.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen ikke-tildelte sager lige nu.</div>';
return;
}
const userOptions = (state.assignmentUsers || []).map((user) => (
`<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>`
)).join('');
const groupOptions = (state.assignmentGroups || []).map((group) => (
`<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>`
)).join('');
list.innerHTML = rows.slice(0, 120).map((item) => {
const caseId = Number(item.id || 0);
const title = item.titel || 'Uden titel';
const desc = truncateText(item.beskrivelse || '', 220);
const currentUserId = Number(item.ansvarlig_bruger_id || 0);
const currentGroupId = Number(item.assigned_group_id || 0);
const hasUser = Number.isFinite(currentUserId) && currentUserId > 0;
const hasGroup = Number.isFinite(currentGroupId) && currentGroupId > 0;
const missingParts = [];
if (!hasUser) missingParts.push('mangler tekniker');
if (!hasGroup) missingParts.push('mangler gruppe');
const userSelectOptions = [
'<option value="">Tildel tekniker...</option>',
userOptions,
].join('');
const groupSelectOptions = [
'<option value="">Tildel gruppe...</option>',
groupOptions,
].join('');
return `
<div class="mc-day-case-card" id="dayCase-${caseId}">
<div class="mc-day-case-title">
<a class="mc-case-link" href="${getCaseHref(caseId)}">#${caseId} ${escapeHtml(title)}</a>
</div>
<div class="mc-day-case-sub">
${escapeHtml(item.customer_name || 'Ukendt kunde')} • Status: ${escapeHtml(item.status || '-')} • Start: ${escapeHtml(formatShortDate(item.start_date))} • Deadline: ${escapeHtml(formatShortDate(item.deadline))}
${missingParts.length ? ` • ${escapeHtml(missingParts.join(' + '))}` : ''}
</div>
${desc ? `<div class="mc-day-case-desc">${escapeHtml(desc)}</div>` : ''}
<div class="mc-day-assign-row">
<select class="mc-day-select" id="assignUser-${caseId}">
${userSelectOptions}
</select>
<select class="mc-day-select" id="assignGroup-${caseId}">
${groupSelectOptions}
</select>
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel</button>
</div>
</div>
`;
}).join('');
rows.slice(0, 120).forEach((item) => {
const caseId = Number(item.id || 0);
const userSelect = document.getElementById(`assignUser-${caseId}`);
const groupSelect = document.getElementById(`assignGroup-${caseId}`);
const currentUserId = Number(item.ansvarlig_bruger_id || 0);
const currentGroupId = Number(item.assigned_group_id || 0);
if (userSelect && Number.isFinite(currentUserId) && currentUserId > 0) {
userSelect.value = String(currentUserId);
}
if (groupSelect && Number.isFinite(currentGroupId) && currentGroupId > 0) {
groupSelect.value = String(currentGroupId);
}
});
}
function renderDayAgentWorkloads() {
const host = document.getElementById('dayAgentsList');
if (!host) return;
const rows = Array.isArray(state.dayAgentWorkloads) ? state.dayAgentWorkloads : [];
if (!rows.length) {
host.innerHTML = '<div class="mc-feed-meta">Ingen aktive agent-opgaver med start/deadline i dag eller tidligere.</div>';
return;
}
host.innerHTML = rows.map((agent) => {
const cases = Array.isArray(agent.case_list) ? agent.case_list : [];
return `
<div class="mc-agent-card">
<div class="mc-agent-head">
<div class="mc-agent-name">${escapeHtml(agent.assignee_name || 'Ufordelt')}</div>
<span class="mc-badge mc-day-mini">${Number(agent.total_cases || 0)} sager</span>
</div>
<div class="mc-agent-metrics">
<span class="mc-badge mc-day-mini warn">I dag: ${Number(agent.due_today_cases || 0)}</span>
<span class="mc-badge mc-day-mini alert">Overskredet: ${Number(agent.overdue_cases || 0)}</span>
<span class="mc-badge mc-day-mini">Startet: ${Number(agent.started_cases || 0)}</span>
</div>
<div class="mc-day-agent-cases">
${cases.length ? cases.map((item) => `
<div class="mc-day-agent-case">
<a class="mc-case-link" href="${getCaseHref(item.id)}">#${Number(item.id || 0)} ${escapeHtml(item.titel || 'Uden titel')}</a>
<div class="mc-case-sub">${escapeHtml(item.customer_name || 'Ukendt kunde')} • Start: ${escapeHtml(formatShortDate(item.start_date))} • Deadline: ${escapeHtml(formatShortDate(item.deadline))}</div>
</div>
`).join('') : '<div class="mc-feed-meta">Ingen sager i listen</div>'}
</div>
</div>
`;
}).join('');
}
async function quickAssignCase(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById(`assignUser-${id}`);
const groupSelect = document.getElementById(`assignGroup-${id}`);
const card = document.getElementById(`dayCase-${id}`);
const button = card?.querySelector('.mc-day-btn');
const ansvarlig_bruger_id = toOptionalInt(userSelect?.value);
const assigned_group_id = toOptionalInt(groupSelect?.value);
if (ansvarlig_bruger_id === null && assigned_group_id === null) {
alert('Vælg tekniker eller gruppe først.');
return;
}
const payload = {
ansvarlig_bruger_id,
assigned_group_id,
};
if (button) {
button.disabled = true;
button.textContent = 'Gemmer...';
}
try {
const res = await fetch(`/api/v1/sag/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || `HTTP ${res.status}`);
}
await loadInitialState();
} catch (error) {
alert(`Kunne ikke tildele sag: ${error?.message || 'ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
button.textContent = 'Tildel';
}
}
}
window.quickAssignCase = quickAssignCase;
function renderEnvironmentReadings() {
const container = document.getElementById('environmentReadings');
if (!container) return;
@ -1750,9 +1286,6 @@
renderActiveCalls();
renderDeadlines();
renderImportantCases();
renderDayTabs();
renderDayUnassignedCases();
renderDayAgentWorkloads();
renderRecentEmails();
renderEnvironmentReadings();
renderFeed();
@ -1771,10 +1304,6 @@
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
state.importantCases = Array.isArray(payload.important_cases) ? payload.important_cases : state.importantCases;
state.dayUnassignedCases = Array.isArray(payload.day_unassigned_cases) ? payload.day_unassigned_cases : state.dayUnassignedCases;
state.dayAgentWorkloads = Array.isArray(payload.day_agent_workloads) ? payload.day_agent_workloads : state.dayAgentWorkloads;
state.assignmentUsers = Array.isArray(payload.assignment_users) ? payload.assignment_users : state.assignmentUsers;
state.assignmentGroups = Array.isArray(payload.assignment_groups) ? payload.assignment_groups : state.assignmentGroups;
state.recentEmails = Array.isArray(payload.recent_emails) ? payload.recent_emails : state.recentEmails;
state.environmentReadings = Array.isArray(payload.environment_readings) ? payload.environment_readings : state.environmentReadings;
@ -1920,14 +1449,6 @@
});
});
document.getElementById('dayTabs')?.querySelectorAll('.mc-day-tab').forEach((btn) => {
btn.addEventListener('click', () => {
state.dayTab = btn.dataset.dayTab || 'new_cases';
renderDayTabs();
resetIdleTimer();
});
});
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
document.addEventListener(name, resetIdleTimer, { passive: true });
});

View File

@ -81,7 +81,7 @@
<td>{{ item.pipeline_stage or '-' }}</td>
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
<td>{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%</td>
<td><a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-primary">Åbn</a></td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
@ -102,7 +102,7 @@
<div class="fw-semibold">{{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.owner_name }}</div>
<div class="small text-muted">Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
<a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>

View File

@ -1,808 +0,0 @@
import logging
from collections import defaultdict
from datetime import date
from decimal import Decimal
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.core.config import settings
from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/economy", tags=["Economy"])
class BulkIdsRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
class BulkUpdateRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
description: Optional[str] = None
original_hours: Optional[float] = Field(None, gt=0)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkSoftDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
reason: Optional[str] = "Soft deleted from economy queue"
class BulkApproveRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
billable: Optional[bool] = None
billing_method: Optional[str] = None
class BulkPrepaidRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
prepaid_card_id: int = Field(..., gt=0)
class BulkSendRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
def _ensure_ids(ids: List[int]) -> List[int]:
clean = sorted(set(int(i) for i in ids if int(i) > 0))
if not clean:
raise HTTPException(status_code=400, detail="No valid ids provided")
return clean
@router.get("/time-queue")
async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0),
status: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(500, ge=1, le=2000),
):
"""List non-billed Hub-created time entries for the economy queue."""
try:
conditions = [
"t.vtiger_id IS NULL",
"t.billed_via_thehub_id IS NULL",
"t.status <> 'billed'",
]
params: List[Any] = []
if customer_id is not None:
conditions.append("t.customer_id = %s")
params.append(customer_id)
if status:
conditions.append("t.status = %s")
params.append(status)
if billable is not None:
conditions.append("COALESCE(t.billable, true) = %s")
params.append(billable)
if q:
conditions.append(
"("
"COALESCE(t.description, '') ILIKE %s OR "
"COALESCE(cust.name, '') ILIKE %s OR "
"COALESCE(c.title, s.titel, '') ILIKE %s"
")"
)
like = f"%{q}%"
params.extend([like, like, like])
where_sql = " AND ".join(conditions)
query = f"""
SELECT
t.id,
t.customer_id,
cust.name AS customer_name,
t.status,
t.entry_status,
t.billable,
t.billing_method,
t.prepaid_card_id,
t.fixed_price_agreement_id,
t.original_hours,
t.approved_hours,
t.rounded_to,
t.worked_date,
t.description,
t.entry_type,
t.kilde,
t.case_id,
t.sag_id,
COALESCE(c.title, s.titel, 'No title') AS case_title,
t.created_at,
t.updated_at
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE {where_sql}
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
LIMIT %s
"""
params.append(limit)
rows = execute_query(query, tuple(params))
return {"items": rows, "count": len(rows)}
except HTTPException:
raise
except Exception as e:
logger.error("Failed listing economy time queue: %s", e)
raise HTTPException(status_code=500, detail="Failed to list time queue")
@router.get("/time-queue/customers")
async def list_time_queue_customers():
"""List customers that currently have queue-relevant (not billed) Hub entries."""
try:
rows = execute_query(
"""
SELECT
t.customer_id,
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
COUNT(*)::int AS open_count
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
WHERE t.customer_id IS NOT NULL
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status = 'pending'
GROUP BY t.customer_id, cust.name
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
"""
)
return {"items": rows, "count": len(rows)}
except Exception as e:
logger.error("Failed listing time queue customers: %s", e)
raise HTTPException(status_code=500, detail="Failed listing customer filter options")
@router.get("/time-queue/prepaid-cards")
async def list_prepaid_cards():
try:
cards = execute_query(
"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
FROM tticket_prepaid_cards
WHERE status IN ('active', 'depleted')
ORDER BY remaining_hours DESC, id DESC
"""
)
return {"items": cards, "count": len(cards)}
except Exception as e:
logger.error("Failed listing prepaid cards: %s", e)
raise HTTPException(status_code=500, detail="Failed to list prepaid cards")
@router.patch("/time-queue/bulk-update")
async def bulk_update_time_queue(payload: BulkUpdateRequest):
ids = _ensure_ids(payload.ids)
updates: List[str] = []
values: List[Any] = []
if payload.description is not None:
updates.append("description = %s")
values.append(payload.description)
if payload.original_hours is not None:
updates.append("original_hours = %s")
values.append(payload.original_hours)
if payload.billable is not None:
updates.append("billable = %s")
values.append(payload.billable)
if payload.billable is False and payload.billing_method is None:
updates.append("billing_method = 'internal'")
if payload.billing_method is not None:
updates.append("billing_method = %s")
values.append(payload.billing_method)
if not updates:
raise HTTPException(status_code=400, detail="No update fields provided")
try:
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(updates)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(values + ids))
return {"success": True, "updated": len(ids)}
except Exception as e:
logger.error("Failed bulk update: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk update")
@router.post("/time-queue/bulk-soft-delete")
async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
ids = _ensure_ids(payload.ids)
reason = (payload.reason or "Soft deleted from economy queue").strip()
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'rejected',
entry_status = 'kladde',
approval_note = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
""",
tuple([reason] + ids),
)
return {"success": True, "soft_deleted": len(ids)}
except Exception as e:
logger.error("Failed bulk soft delete: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk soft delete")
@router.post("/time-queue/bulk-approve")
async def bulk_approve_time_queue(payload: BulkApproveRequest):
ids = _ensure_ids(payload.ids)
try:
set_parts = [
"status = 'approved'",
"entry_status = 'godkendt'",
"approved_hours = COALESCE(approved_hours, original_hours)",
"approved_at = CURRENT_TIMESTAMP",
"updated_at = CURRENT_TIMESTAMP",
]
params: List[Any] = []
if payload.billable is not None:
set_parts.append("billable = %s")
params.append(payload.billable)
if payload.billing_method is not None:
set_parts.append("billing_method = %s")
params.append(payload.billing_method)
placeholders = ",".join(["%s"] * len(ids))
query = f"""
UPDATE tmodule_times
SET {", ".join(set_parts)}
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(params + ids))
return {"success": True, "approved": len(ids)}
except Exception as e:
logger.error("Failed bulk approve: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk approve")
@router.post("/time-queue/bulk-apply-prepaid")
async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
ids = _ensure_ids(payload.ids)
card = execute_query_single(
"SELECT id FROM tticket_prepaid_cards WHERE id = %s",
(payload.prepaid_card_id,),
)
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
try:
placeholders = ",".join(["%s"] * len(ids))
execute_update(
f"""
UPDATE tmodule_times
SET prepaid_card_id = %s,
billing_method = 'prepaid',
billable = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND status <> 'billed'
""",
tuple([payload.prepaid_card_id] + ids),
)
return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id}
except Exception as e:
logger.error("Failed applying prepaid card: %s", e)
raise HTTPException(status_code=500, detail="Failed applying prepaid card")
def _create_order_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))
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]] = []
total_hours = Decimal("0")
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
line_total = (qty * hourly_rate).quantize(Decimal("0.01"))
line_payloads.append(
{
"description": group["case_title"],
"quantity": qty,
"line_total": line_total,
"time_entry_ids": ids,
"case_id": group["case_id"],
"sag_id": group["sag_id"],
"time_date": latest_date,
}
)
total_hours += qty
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
vat_rate = Decimal("25.00")
vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
total_amount = subtotal + vat_amount
order_id = execute_insert(
"""
INSERT INTO tmodule_orders
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
VALUES
(%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
customer_id,
customer.get("hub_customer_id"),
total_hours,
hourly_rate,
subtotal,
vat_rate,
vat_amount,
total_amount,
user_id,
),
)
for idx, line in enumerate(line_payloads, start=1):
execute_insert(
"""
INSERT INTO tmodule_order_lines
(order_id, case_id, sag_id, line_number, description, quantity, unit_price,
line_total, time_entry_ids, case_contact, time_date, is_travel)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE)
RETURNING id
""",
(
order_id,
line["case_id"],
line["sag_id"],
idx,
line["description"],
line["quantity"],
hourly_rate,
line["line_total"],
line["time_entry_ids"],
line["time_date"],
),
)
return int(order_id)
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
hub_customer_id = customer.get("hub_customer_id")
hub_customer = None
if hub_customer_id:
hub_customer = execute_query_single(
"""
SELECT
standard_hourly_rate,
standard_margin_percent,
special_freight_price,
supplier_service_enrolled,
invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
invoice_fee_amount = Decimal(
str(
(hub_customer or {}).get("invoice_fee_amount")
if (hub_customer or {}).get("invoice_fee_amount") is not None
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
)
)
special_freight_price = (hub_customer or {}).get("special_freight_price")
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
standard_margin_percent = Decimal(
str(
(hub_customer or {}).get("standard_margin_percent")
if (hub_customer or {}).get("standard_margin_percent") is not None
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
)
)
base_hourly_rate = Decimal(
str(
(hub_customer or {}).get("standard_hourly_rate")
if (hub_customer or {}).get("standard_hourly_rate") is not None
else hourly_rate
)
)
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"rows": [],
"case_title": "Time entries",
"case_id": None,
"sag_id": None,
})
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
grouped[group_key]["sag_id"] = row.get("sag_id")
line_payloads: List[Dict[str, Any]] = []
for _, group in grouped.items():
qty = Decimal("0")
ids: List[int] = []
latest_date = None
for row in group["rows"]:
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
ids.append(int(row["id"]))
wd = row.get("worked_date")
if wd and (latest_date is None or wd > latest_date):
latest_date = wd
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
amount = (qty * unit_price).quantize(Decimal("0.01"))
line_payloads.append(
{
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
"source_type": "timequeue",
"source_id": ids[0] if ids else None,
"description": group["case_title"],
"quantity": float(qty),
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "timer",
"product_id": None,
"selected": True,
"amount": float(amount),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": group["sag_id"],
"time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None,
"meta": {
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
"standard_margin_percent": float(effective_margin_percent),
},
}
)
if special_freight_amount > 0:
line_payloads.append(
{
"line_key": f"freight:{hub_customer_id or customer_id}",
"source_type": "freight",
"source_id": None,
"description": "Særlig fragtpris",
"quantity": 1.0,
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
}
)
# Fee line is included by default unless customer-specific value is 0.
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
"quantity": 1.0,
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
"meta": {
"standard_margin_percent": float(standard_margin_percent),
"supplier_service_enrolled": supplier_service_enrolled,
},
}
)
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
invoice_aggregate_key,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
RETURNING id
""",
(
draft_title,
int(hub_customer_id) if hub_customer_id else None,
json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue",
1,
user_id,
json.dumps({}, ensure_ascii=False),
invoice_aggregate_key,
),
)
if not draft:
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
return int(draft["id"])
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
Accepts:
- direct tmodule customer id
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
"""
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
return int(row["id"]) if row else None
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
row = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
return int(row["id"]) if row else None
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct = _find_by_tmodule_id(cid)
if direct:
return direct
mapped = _find_by_hub_customer_id(cid)
if mapped:
return mapped
if sag_id is not None:
try:
sid = int(sag_id)
except (TypeError, ValueError):
sid = None
if sid and sid > 0:
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id:
mapped = _find_by_hub_customer_id(int(hub_customer_id))
if mapped:
return mapped
return None
@router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
ids = _ensure_ids(payload.ids)
user_id = getattr(request.state, "user_id", None)
try:
placeholders = ",".join(["%s"] * len(ids))
rows = execute_query(
f"""
SELECT
t.id,
t.customer_id,
t.case_id,
t.sag_id,
t.status,
t.billable,
t.billing_method,
t.original_hours,
t.approved_hours,
t.worked_date,
COALESCE(c.title, s.titel, 'Time entries') AS case_title
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.id IN ({placeholders})
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.status <> 'billed'
""",
tuple(ids),
)
if not rows:
raise HTTPException(status_code=400, detail="No eligible entries found")
# Local order creation must not depend on e-conomic data/mapping.
# Selected entries are converted to local orders regardless of billing method.
selected_order_ids = [int(r["id"]) for r in rows]
if not selected_order_ids:
raise HTTPException(status_code=400, detail="No selected entries found")
placeholders_invoice = ",".join(["%s"] * len(selected_order_ids))
execute_update(
f"""
UPDATE tmodule_times
SET status = 'approved',
entry_status = 'godkendt',
approved_hours = COALESCE(approved_hours, original_hours),
approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders_invoice})
AND status <> 'billed'
""",
tuple(selected_order_ids),
)
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
skipped_missing_customer: List[int] = []
for row in rows:
if int(row["id"]) not in selected_order_ids:
continue
resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not resolved_customer_id:
skipped_missing_customer.append(int(row["id"]))
continue
rows_by_customer[int(resolved_customer_id)].append(row)
created_drafts = []
failed_customers: List[Dict[str, Any]] = []
for cust_id, cust_rows in rows_by_customer.items():
try:
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
except HTTPException as ex:
failed_customers.append(
{
"customer_id": cust_id,
"entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
"error": str(ex.detail),
}
)
if not created_drafts:
if skipped_missing_customer:
raise HTTPException(
status_code=400,
detail="No local orders created: selected entries are missing customer linkage",
)
if failed_customers:
raise HTTPException(
status_code=400,
detail="No local orders created: customer data is invalid for selected entries",
)
raise HTTPException(status_code=400, detail="No local orders created")
# Time queue must never push directly to e-conomic.
# Orders are created locally and can be transferred manually from Orders page.
draft_ids = [o["draft_id"] for o in created_drafts]
orders_url = "/ordre"
if len(draft_ids) == 1:
orders_url = f"/ordre/{draft_ids[0]}"
return {
"success": True,
"selected": len(ids),
"order_candidates": len(selected_order_ids),
"created_drafts": created_drafts,
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
"skipped_missing_customer": skipped_missing_customer,
"failed_customers": failed_customers,
"orders_url": orders_url,
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed send-to-invoices flow: %s", e)
raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")

View File

@ -1,510 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Economy Time Queue{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
<div>
<h2 class="mb-1">Economy Time Queue</h2>
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
</div>
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
</div>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="filterCustomer" class="form-label">Firma</label>
<select id="filterCustomer" class="form-select">
<option value="">Alle firmaer med ubehandlede registreringer</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterStatus" class="form-label">Status</label>
<select id="filterStatus" class="form-select">
<option value="">All</option>
<option value="pending">pending</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterBillable" class="form-label">Billable</label>
<select id="filterBillable" class="form-select">
<option value="">All</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterQuery" class="form-label">Search</label>
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="bulkDescription" class="form-label">Description</label>
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
</div>
<div class="col-12 col-md-2">
<label for="bulkHours" class="form-label">Hours</label>
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
</div>
<div class="col-12 col-md-2">
<label for="bulkBillingMethod" class="form-label">Billing method</label>
<select id="bulkBillingMethod" class="form-select">
<option value="">No change</option>
<option value="invoice">invoice</option>
<option value="internal">internal</option>
<option value="prepaid">prepaid</option>
<option value="fixed_price">fixed_price</option>
</select>
</div>
<div class="col-12 col-md-2">
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
<select id="bulkPrepaidCard" class="form-select">
<option value="">Select card</option>
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
<th>ID</th>
<th>Customer</th>
<th>Date</th>
<th>Case</th>
<th>Hours</th>
<th>Status</th>
<th>Billable</th>
<th>Method</th>
<th>Hours edit</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queueBody">
<tr>
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(() => {
const state = {
items: [],
selected: new Set(),
loading: false,
};
const queueBody = document.getElementById('queueBody');
const selectAll = document.getElementById('selectAll');
const selectedCountBadge = document.getElementById('selectedCountBadge');
const filterCustomer = document.getElementById('filterCustomer');
const filterStatus = document.getElementById('filterStatus');
const filterBillable = document.getElementById('filterBillable');
const filterQuery = document.getElementById('filterQuery');
const bulkDescription = document.getElementById('bulkDescription');
const bulkHours = document.getElementById('bulkHours');
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
function selectedIds() {
return Array.from(state.selected);
}
function renderRows() {
if (!state.items.length) {
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
return;
}
queueBody.innerHTML = state.items.map((item) => {
const id = Number(item.id);
const checked = state.selected.has(id) ? 'checked' : '';
const date = item.worked_date || '-';
const hours = item.approved_hours || item.original_hours || 0;
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
const title = item.case_title || '-';
const desc = (item.description || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const method = item.billing_method || 'invoice';
return `
<tr>
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
<td>${id}</td>
<td>${customer}</td>
<td>${date}</td>
<td>${title}</td>
<td>${hours}</td>
<td>${item.status || '-'}</td>
<td>${item.billable === false ? 'false' : 'true'}</td>
<td>
<select class="form-select form-select-sm inline-method" data-id="${id}">
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
</select>
</td>
<td>
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
</td>
<td>
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
</td>
<td>
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
</td>
</tr>
`;
}).join('');
document.querySelectorAll('.row-check').forEach((cb) => {
cb.addEventListener('change', (e) => {
const id = Number(e.target.dataset.id);
if (e.target.checked) state.selected.add(id);
else state.selected.delete(id);
syncSelectAll();
});
});
document.querySelectorAll('.inline-save').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const id = Number(e.target.dataset.id);
await saveInlineRow(id, e.target);
});
});
syncSelectAll();
}
function syncSelectAll() {
const ids = state.items.map((x) => Number(x.id));
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
selectAll.checked = Boolean(allSelected);
selectedCountBadge.textContent = `${state.selected.size} selected`;
}
async function api(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
function buildListUrl() {
const params = new URLSearchParams();
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (filterBillable.value) params.set('billable', filterBillable.value);
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
params.set('limit', '500');
return `/api/v1/economy/time-queue?${params.toString()}`;
}
async function loadEntries() {
if (state.loading) return;
state.loading = true;
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
try {
const data = await api(buildListUrl());
state.items = data.items || [];
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
renderRows();
} catch (err) {
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
} finally {
state.loading = false;
}
}
async function loadPrepaidCards() {
try {
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
const opts = ['<option value="">Select card</option>'];
(data.items || []).forEach((card) => {
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
opts.push(`<option value="${card.id}">${label}</option>`);
});
bulkPrepaidCard.innerHTML = opts.join('');
} catch (_) {
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
}
}
async function loadCustomers() {
try {
const data = await api('/api/v1/economy/time-queue/customers');
const current = filterCustomer.value;
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
(data.items || []).forEach((row) => {
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
opts.push(`<option value="${row.customer_id}">${label}</option>`);
});
filterCustomer.innerHTML = opts.join('');
if (current) {
filterCustomer.value = current;
}
} catch (_) {
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
}
}
async function clearFilters() {
filterCustomer.value = '';
filterStatus.value = '';
filterBillable.value = '';
filterQuery.value = '';
setActiveQuickFilter(null);
await loadEntries();
}
function setActiveQuickFilter(active) {
quickFilterBtns.forEach((btn) => {
const isActive = btn.dataset.filter === active;
btn.classList.toggle('btn-primary', isActive);
btn.classList.toggle('btn-outline-primary', !isActive);
});
}
async function applyQuickFilter(type) {
if (type === 'pending') {
filterStatus.value = 'pending';
filterBillable.value = '';
} else if (type === 'billable') {
filterStatus.value = '';
filterBillable.value = 'true';
} else if (type === 'ready') {
filterStatus.value = 'approved';
filterBillable.value = 'true';
}
setActiveQuickFilter(type);
await loadEntries();
}
async function saveInlineRow(id, buttonEl) {
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
if (!hoursInput || !descInput || !methodSelect) return;
const originalHours = Number(hoursInput.value);
const description = (descInput.value || '').trim();
const billingMethod = methodSelect.value;
if (!originalHours || originalHours <= 0) {
alert('Hours must be greater than 0');
return;
}
const prevText = buttonEl.textContent;
buttonEl.disabled = true;
buttonEl.textContent = 'Gemmer...';
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify({
ids: [id],
original_hours: originalHours,
description,
billing_method: billingMethod,
}),
});
await loadEntries();
await loadCustomers();
} catch (err) {
alert(err.message);
} finally {
buttonEl.disabled = false;
buttonEl.textContent = prevText;
}
}
async function doBulkUpdate() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
if (!payload.description && !payload.original_hours && !payload.billing_method) {
return alert('Set at least one update field');
}
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkApprove() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
try {
await api('/api/v1/economy/time-queue/bulk-approve', {
method: 'POST',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkDelete() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
if (reason === null) return;
try {
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
method: 'POST',
body: JSON.stringify({ ids, reason }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doApplyPrepaid() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
try {
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
method: 'POST',
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doSendInvoices() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
if (!ok) return;
try {
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
method: 'POST',
body: JSON.stringify({ ids }),
});
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
const draftId = x.draft_id || x.order_id;
return `customer ${x.customer_id}, draft ${draftId}`;
}).join('\n');
const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
: '';
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
selectAll.addEventListener('change', () => {
if (selectAll.checked) {
state.items.forEach((item) => state.selected.add(Number(item.id)));
} else {
state.items.forEach((item) => state.selected.delete(Number(item.id)));
}
renderRows();
});
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
quickFilterBtns.forEach((btn) => {
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
});
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
el.addEventListener('change', loadEntries);
});
filterQuery.addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadEntries();
});
loadCustomers();
loadPrepaidCards();
loadEntries();
})();
</script>
{% endblock %}

View File

@ -1,14 +0,0 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/economy/time-queue", response_class=HTMLResponse)
async def economy_time_queue_page(request: Request):
return templates.TemplateResponse(
"economy/frontend/time_queue.html",
{"request": request, "title": "Economy Time Queue"},
)

View File

@ -4,11 +4,10 @@ API endpoints for email viewing, classification, and rule management
"""
import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime, date
import unicodedata
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.services.email_processor_service import EmailProcessorService
@ -21,218 +20,6 @@ router = APIRouter()
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
IGNORED_SENDER_DOMAINS = {
"bmcnetworks.dk",
"bmchub.local",
"outlook.com",
"hotmail.com",
"gmail.com",
"icloud.com",
"yahoo.com",
"live.com",
}
def _normalize_domain(value: Optional[str]) -> str:
domain = str(value or "").strip().lower()
if not domain:
return ""
if domain.startswith("www."):
return domain[4:]
return domain
def _extract_sender_domain(sender_email: Optional[str]) -> str:
sender = str(sender_email or "").strip().lower()
if "@" not in sender:
return ""
return _normalize_domain(sender.split("@", 1)[1])
def _is_ignored_sender_domain(domain: str) -> bool:
if not domain:
return True
return domain in IGNORED_SENDER_DOMAINS or "bmc" in domain
def _upsert_domain_mapping(domain: str, customer_id: int, source: str = "manual") -> None:
if not domain or not customer_id:
return
try:
execute_update(
"""
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
VALUES (%s, %s, %s)
ON CONFLICT (domain)
DO UPDATE SET
customer_id = EXCLUDED.customer_id,
source = EXCLUDED.source,
updated_at = CURRENT_TIMESTAMP
""",
(domain, customer_id, source),
)
except Exception as e:
# Keep linking flow operational even if mapping table is not migrated yet.
logger.warning("⚠️ Could not upsert domain mapping for %s: %s", domain, e)
def _resolve_procurement_customer_id() -> Optional[int]:
"""Resolve a fallback customer for supplier/procurement case creation."""
bmc_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE is_active = true
AND LOWER(name) LIKE %s
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id
LIMIT 1
""",
("%bmc%", "%bmc networks%")
)
if bmc_row:
return int(bmc_row["id"])
fallback = execute_query_single(
"SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1"
)
if fallback:
return int(fallback["id"])
return None
def _normalize_case_type(value: Optional[str]) -> str:
raw = str(value or "").strip().lower()
if not raw:
return "support"
normalized = unicodedata.normalize("NFKD", raw)
ascii_value = normalized.encode("ascii", "ignore").decode("ascii").strip().lower()
return ascii_value or raw
def _is_supplier_case_type(case_type: Optional[str]) -> bool:
value = _normalize_case_type(case_type)
if value in {
"indkob",
"indkoeb",
"supplier",
"leverandor",
"leverandoer",
"vendor",
"procurement",
"purchase",
}:
return True
return "indk" in value or "leverand" in value or "supplier" in value
def _extract_domain_from_email(email: Optional[str]) -> str:
sender = str(email or "").strip().lower()
if "@" not in sender:
return ""
return _normalize_domain(sender.split("@", 1)[1])
def _find_customer_for_vendor(vendor: Dict) -> Optional[int]:
cvr = str(vendor.get("cvr_number") or "").strip()
if cvr:
row = execute_query_single(
"SELECT id FROM customers WHERE cvr_number = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
(cvr,),
)
if row:
return int(row["id"])
email = str(vendor.get("email") or "").strip().lower()
if email:
row = execute_query_single(
"SELECT id FROM customers WHERE LOWER(TRIM(email)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
(email,),
)
if row:
return int(row["id"])
domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email")))
if domain:
row = execute_query_single(
"""
SELECT id
FROM customers
WHERE COALESCE(is_active, true) = true
AND (
LOWER(TRIM(COALESCE(email_domain, ''))) = %s
OR LOWER(TRIM(COALESCE(email_domain, ''))) = %s
)
ORDER BY id
LIMIT 1
""",
(domain, f"www.{domain}"),
)
if row:
return int(row["id"])
name = str(vendor.get("name") or "").strip().lower()
if name:
row = execute_query_single(
"SELECT id FROM customers WHERE LOWER(TRIM(name)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
(name,),
)
if row:
return int(row["id"])
return None
def _ensure_customer_from_vendor(vendor_id: Optional[int]) -> Optional[int]:
if not vendor_id:
return None
vendor = execute_query_single(
"""
SELECT id, name, email, phone, address, cvr_number, domain, city, postal_code, country, website
FROM vendors
WHERE id = %s AND is_active = true
""",
(vendor_id,),
)
if not vendor:
return None
existing_customer_id = _find_customer_for_vendor(vendor)
if existing_customer_id:
return existing_customer_id
name = str(vendor.get("name") or "").strip()
if not name:
return None
domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email"))) or None
try:
created_id = execute_insert(
"""
INSERT INTO customers (name, email, phone, address, cvr_number, email_domain, city, postal_code, country, website, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
RETURNING id
""",
(
name,
vendor.get("email"),
vendor.get("phone"),
vendor.get("address"),
vendor.get("cvr_number"),
domain,
vendor.get("city"),
vendor.get("postal_code"),
vendor.get("country") or "DK",
vendor.get("website"),
),
)
return int(created_id)
except Exception:
# Handle potential race/unique conflict by resolving again.
return _find_customer_for_vendor(vendor)
# Pydantic Models
class EmailListItem(BaseModel):
@ -381,45 +168,6 @@ class CreateSagFromEmailRequest(BaseModel):
relation_type: str = "mail"
class EmailReadStateUpdate(BaseModel):
is_read: bool
def _can_user_mark_case_email_read(user_id: Optional[int], linked_case_id: Optional[int]) -> bool:
"""Allow read-marking only for assignee user or assignee group members."""
if not linked_case_id:
# Non-case emails can still be marked read.
return True
if not user_id:
return False
case_row = execute_query_single(
"""
SELECT ansvarlig_bruger_id, assigned_group_id
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(linked_case_id,),
) or {}
assigned_user_id = case_row.get("ansvarlig_bruger_id")
assigned_group_id = case_row.get("assigned_group_id")
if assigned_user_id is not None and int(assigned_user_id) == int(user_id):
return True
if assigned_group_id is not None:
user_group = execute_query_single(
"SELECT 1 FROM user_groups WHERE user_id = %s AND group_id = %s LIMIT 1",
(user_id, assigned_group_id),
)
if user_group:
return True
return False
class LinkEmailToSagRequest(BaseModel):
sag_id: int
relation_type: str = "mail"
@ -438,12 +186,6 @@ class RewriteEmailTextResponse(BaseModel):
context: Optional[str] = None
class DomainMappingUpsertRequest(BaseModel):
domain: str
customer_id: int
source: Optional[str] = "manual"
@router.get("/emails/sag-options")
async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI."""
@ -513,222 +255,6 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/{email_id}/domain-customer-suggestion")
async def get_domain_customer_suggestion(email_id: int):
"""Suggest customer based on sender domain for mails without known contact/customer."""
try:
email_row = execute_query_single(
"SELECT id, sender_email, customer_id FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not email_row:
raise HTTPException(status_code=404, detail="Email not found")
if email_row.get("customer_id"):
return {
"email_id": email_id,
"domain": None,
"has_customer": True,
"ignored": False,
"suggestion": None,
}
sender_email = str(email_row.get("sender_email") or "").strip().lower()
if sender_email:
contact_match = execute_query_single(
"""
SELECT
c.id,
c.name,
c.email_domain,
c.cvr_number,
ct.id AS contact_id,
ct.first_name,
ct.last_name
FROM contacts ct
JOIN contact_companies cc ON cc.contact_id = ct.id
JOIN customers c ON c.id = cc.customer_id
WHERE c.is_active = true
AND LOWER(TRIM(COALESCE(ct.email, ''))) = %s
ORDER BY cc.is_primary DESC, c.id ASC
LIMIT 1
""",
(sender_email,),
)
if contact_match:
contact_name = " ".join(
part for part in [contact_match.get("first_name"), contact_match.get("last_name")] if part
).strip()
return {
"email_id": email_id,
"domain": _extract_sender_domain(sender_email),
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": contact_match["id"],
"customer_name": contact_match["name"],
"email_domain": contact_match.get("email_domain"),
"cvr_number": contact_match.get("cvr_number"),
"confidence": "high",
"score": 110,
"source": f"contact_email:{contact_name or sender_email}",
},
}
sender_domain = _extract_sender_domain(email_row.get("sender_email"))
if not sender_domain:
return {
"email_id": email_id,
"domain": None,
"has_customer": False,
"ignored": True,
"reason": "no_domain",
"suggestion": None,
}
if _is_ignored_sender_domain(sender_domain):
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": True,
"reason": "ignored_domain",
"suggestion": None,
}
mapped = execute_query_single(
"""
SELECT c.id, c.name, c.email_domain, c.cvr_number, m.source
FROM email_domain_customer_mappings m
JOIN customers c ON c.id = m.customer_id
WHERE m.domain = %s
AND c.is_active = true
LIMIT 1
""",
(sender_domain,),
)
if mapped:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": mapped["id"],
"customer_name": mapped["name"],
"email_domain": mapped.get("email_domain"),
"cvr_number": mapped.get("cvr_number"),
"confidence": "high",
"score": 100,
"source": f"mapping:{mapped.get('source') or 'manual'}",
},
}
exact = execute_query_single(
"""
SELECT id, name, email_domain, cvr_number
FROM customers
WHERE is_active = true
AND (
LOWER(TRIM(email_domain)) = %s
OR LOWER(TRIM(email_domain)) = %s
)
ORDER BY id ASC
LIMIT 1
""",
(sender_domain, f"www.{sender_domain}"),
)
if exact:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": exact["id"],
"customer_name": exact["name"],
"email_domain": exact.get("email_domain"),
"cvr_number": exact.get("cvr_number"),
"confidence": "high",
"score": 95,
"source": "exact_domain",
},
}
partial = execute_query_single(
"""
SELECT id, name, email_domain, cvr_number
FROM customers
WHERE is_active = true
AND COALESCE(email_domain, '') ILIKE %s
ORDER BY name ASC
LIMIT 1
""",
(f"%{sender_domain}%",),
)
if partial:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": partial["id"],
"customer_name": partial["name"],
"email_domain": partial.get("email_domain"),
"cvr_number": partial.get("cvr_number"),
"confidence": "medium",
"score": 70,
"source": "partial_domain",
},
}
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": None,
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error getting domain customer suggestion: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/domain-customer-mapping")
async def upsert_domain_customer_mapping(payload: DomainMappingUpsertRequest):
"""Persist trusted mapping from sender domain to customer."""
try:
domain = _normalize_domain(payload.domain)
if not domain:
raise HTTPException(status_code=400, detail="domain is required")
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s AND is_active = true",
(payload.customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
_upsert_domain_mapping(domain, int(payload.customer_id), payload.source or "manual")
return {
"success": True,
"domain": domain,
"customer_id": int(payload.customer_id),
"source": payload.source or "manual",
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error upserting domain mapping: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
async def rewrite_email_text(request: RewriteEmailTextRequest):
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
@ -843,7 +369,7 @@ async def list_emails(
@router.get("/emails/{email_id:int}", response_model=EmailDetail)
async def get_email(email_id: int, request: Request):
async def get_email(email_id: int):
"""Get email detail by ID"""
try:
query = """
@ -871,14 +397,9 @@ async def get_email(email_id: int, request: Request):
attachments = execute_query(att_query, (email_id,))
email_data['attachments'] = attachments or []
user_id = getattr(request.state, "user_id", None)
linked_case_id = email_data.get("linked_case_id")
can_mark_read = _can_user_mark_case_email_read(user_id, linked_case_id)
if not bool(email_data.get("is_read")) and can_mark_read:
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
execute_update(update_query, (email_id,))
email_data["is_read"] = True
# Mark as read
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
execute_update(update_query, (email_id,))
return email_data
@ -889,38 +410,6 @@ async def get_email(email_id: int, request: Request):
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/emails/{email_id:int}/read-state")
async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate, request: Request):
"""Toggle read/unread state for an email.
Marking as read on case-linked emails is restricted to case assignee user/group.
"""
try:
row = execute_query_single(
"SELECT id, linked_case_id, is_read FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Email not found")
user_id = getattr(request.state, "user_id", None)
if payload.is_read:
can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id"))
if not can_mark_read:
raise HTTPException(status_code=403, detail="Email kan ikke markeres som laest: sag er ikke tildelt dig/din gruppe")
execute_update(
"UPDATE email_messages SET is_read = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(payload.is_read, email_id),
)
return {"success": True, "email_id": email_id, "is_read": payload.is_read}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating read-state for email {email_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/mark-processed")
async def mark_email_processed(email_id: int):
"""Mark email as processed and move to 'Processed' folder"""
@ -1043,16 +532,6 @@ async def link_email(email_id: int, payload: Dict):
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
execute_update(query, tuple(params))
customer_id = payload.get('customer_id')
if customer_id:
email_row = execute_query_single(
"SELECT sender_email FROM email_messages WHERE id = %s",
(email_id,),
)
sender_domain = _extract_sender_domain((email_row or {}).get("sender_email"))
if sender_domain and not _is_ignored_sender_domain(sender_domain):
_upsert_domain_mapping(sender_domain, int(customer_id), "auto_link")
logger.info(f"✅ Linked email {email_id}: {payload}")
return {"success": True, "message": "Email linket"}
@ -1075,59 +554,13 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_row[0]
# Idempotent safeguard: repeated clicks should return existing linked case.
existing_sag_id = email_data.get('linked_case_id')
if existing_sag_id:
existing_sag = execute_query_single(
"""
SELECT id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(existing_sag_id,),
)
if existing_sag:
execute_update(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT (sag_id, email_id) DO NOTHING
""",
(existing_sag_id, email_id),
)
return {
"success": True,
"email_id": email_id,
"sag": existing_sag,
"idempotent": True,
"message": "E-mail er allerede knyttet til eksisterende SAG"
}
requested_case_type = _normalize_case_type(payload.case_type)
customer_id = payload.customer_id or email_data.get('customer_id')
if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _resolve_procurement_customer_id()
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
if not email_data.get('customer_id') and customer_id:
execute_update(
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(customer_id, email_id),
)
sender_domain = _extract_sender_domain(email_data.get("sender_email"))
if sender_domain and not _is_ignored_sender_domain(sender_domain):
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
template_key = requested_case_type[:50]
template_key = (payload.case_type or 'support').strip().lower()[:50]
priority = (payload.priority or 'normal').strip().lower()
if priority not in {'low', 'normal', 'high', 'urgent'}:
@ -1183,25 +616,6 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
(sag_id, email_id)
)
attachments_linked = 0
try:
# Reuse workflow helper so attachments become real sag_files entries.
attachments_linked = int(email_workflow_service._copy_email_attachments_to_case(email_id, sag_id, None) or 0)
if attachments_linked > 0:
logger.info(
"📎 Linked %s attachment(s) from email %s to SAG-%s during create-sag",
attachments_linked,
email_id,
sag_id,
)
except Exception as attach_exc:
logger.warning(
"⚠️ Could not auto-link attachments from email %s to SAG-%s: %s",
email_id,
sag_id,
attach_exc,
)
if payload.contact_id:
execute_update(
"""
@ -1220,7 +634,6 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
"success": True,
"email_id": email_id,
"sag": sag,
"attachments_linked": attachments_linked,
"message": "SAG oprettet fra e-mail"
}

File diff suppressed because it is too large Load Diff

View File

@ -243,7 +243,7 @@
</td>
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
<td>
<a href="/sag/{{ sag.id }}/v3" class="btn btn-sm btn-outline-primary">
<a href="/sag/{{ sag.id }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Vis
</a>
</td>
@ -284,7 +284,7 @@
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
<td>
{% if entry.sag_id %}
<a href="/sag/{{ entry.sag_id }}/v3">#{{ entry.sag_id }}</a>
<a href="/sag/{{ entry.sag_id }}">#{{ entry.sag_id }}</a>
{% if entry.sag_titel %}
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
{% endif %}

View File

@ -1,41 +0,0 @@
"""
AnyDesk local sessions sync job.
Polls local AnyDesk bridge endpoint and enriches local session rows.
"""
import logging
from app.core.config import settings
from app.services.anydesk import AnyDeskService
logger = logging.getLogger(__name__)
anydesk_service = AnyDeskService()
async def sync_anydesk_local_sessions():
"""Sync AnyDesk sessions from local endpoint every N minutes."""
if not settings.ANYDESK_LOCAL_SYNC_ENABLED:
return
try:
logger.info("🔄 AnyDesk local sync started")
result = await anydesk_service.fetch_sessions_from_local_endpoint(
endpoint_url=settings.ANYDESK_LOCAL_SESSIONS_URL,
timeout_seconds=settings.ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS,
dry_run=settings.ANYDESK_LOCAL_SYNC_DRY_RUN,
)
if result.get("error"):
logger.error("❌ AnyDesk local sync failed: %s", result["error"])
return
logger.info(
"✅ AnyDesk local sync completed: total=%s imported=%s updated=%s matched=%s errors=%s",
result.get("total", 0),
result.get("imported", 0),
result.get("updated", 0),
result.get("matched", 0),
len(result.get("errors") or []),
)
except Exception as exc:
logger.error("❌ Unexpected AnyDesk local sync error: %s", exc)

View File

@ -77,7 +77,7 @@ async def _process_reminder_queue():
# Get assigned user name
assigned_user = None
if event['ansvarlig_bruger_id']:
user_query = "SELECT full_name FROM users WHERE user_id = %s"
user_query = "SELECT full_name FROM users WHERE id = %s"
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
assigned_user = user[0]['full_name'] if user else None
@ -174,7 +174,7 @@ async def _process_time_based_reminders():
# Get assigned user name
assigned_user = None
if reminder['ansvarlig_bruger_id']:
user_query = "SELECT full_name FROM users WHERE user_id = %s"
user_query = "SELECT full_name FROM users WHERE id = %s"
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
assigned_user = user[0]['full_name'] if user else None

View File

@ -79,35 +79,6 @@ def _extract_full_name(payload: Any) -> Optional[str]:
return None
def _extract_login_candidates(payload: Any) -> List[str]:
raw = _extract_first_str(
payload,
["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"]
)
if not raw:
return []
candidates: List[str] = []
def _add(value: str) -> None:
v = (value or "").strip().lower()
if v and v not in candidates:
candidates.append(v)
_add(raw)
# DOMAIN\\user or provider/user -> user
if "\\" in raw:
_add(raw.split("\\")[-1])
if "/" in raw:
_add(raw.split("/")[-1])
# email local-part fallback
if "@" in raw:
_add(raw.split("@", 1)[0])
return candidates
def _detect_asset_type(payload: Any) -> str:
device_type = _extract_first_str(payload, ["deviceType", "type"])
if device_type:
@ -133,57 +104,6 @@ def _match_contact(full_name: str, company: str) -> Optional[int]:
return None
def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]:
if not login_candidate:
return None
# Try scoped match first when company is known to reduce false positives.
if company:
scoped_query = """
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
scoped = execute_query(scoped_query, (login_candidate, company))
if scoped:
return scoped[0]["id"]
scoped_local_part_query = """
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
scoped_local_part = execute_query(scoped_local_part_query, (login_candidate, company))
if scoped_local_part:
return scoped_local_part[0]["id"]
email_query = """
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
LIMIT 1
"""
by_email = execute_query(email_query, (login_candidate,))
if by_email:
return by_email[0]["id"]
local_part_query = """
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
LIMIT 1
"""
by_local_part = execute_query(local_part_query, (login_candidate,))
if by_local_part:
return by_local_part[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
@ -293,14 +213,7 @@ async def sync_eset_hardware() -> None:
full_name = _extract_full_name(details)
company = _extract_company(details)
login_candidates = _extract_login_candidates(details)
contact_id = _match_contact(full_name, company) if full_name and company else None
if not contact_id:
for login_candidate in login_candidates:
contact_id = _match_contact_by_login(login_candidate, company)
if contact_id:
break
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None
@ -324,16 +237,6 @@ async def sync_eset_hardware() -> None:
update_fields.append("brand = %s")
update_params.append(brand)
# Auto-created ESET devices are customer devices by default unless explicitly reassigned.
if customer_id:
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_fields.append("current_owner_customer_id = %s")
update_params.append(customer_id)
elif existing[0].get("notes") == "Auto-created from ESET" and existing[0].get("current_owner_type") != "customer":
update_fields.append("current_owner_type = %s")
update_params.append("customer")
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
@ -342,8 +245,7 @@ async def sync_eset_hardware() -> None:
"""
execute_query(update_query, tuple(update_params))
else:
# ESET sync auto-creates customer endpoints; ownership can be refined later if needed.
owner_type = "customer"
owner_type = "customer" if customer_id else "bmc"
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,

View File

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

View File

@ -1,121 +0,0 @@
import asyncio
import json
import logging
from typing import Optional
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from app.core.auth_service import AuthService
from .service import get_active_timer, get_dashboard_status, get_notifications
logger = logging.getLogger(__name__)
router = APIRouter()
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
try:
return int(user_id)
except (TypeError, ValueError):
return None
user_id_param = request.query_params.get("user_id")
if user_id_param:
try:
return int(user_id_param)
except (TypeError, ValueError):
return None
return None
def _resolve_ws_payload(websocket: WebSocket) -> Optional[dict]:
token = websocket.query_params.get("token")
auth_header = (websocket.headers.get("authorization") or "").strip()
if not token and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
payload = AuthService.verify_token(token) if token else None
if not payload:
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
return payload
@router.get("/api/v1/dashboard/status")
async def get_dashboard_status_endpoint() -> dict:
return get_dashboard_status()
@router.get("/api/v1/timer/active")
async def get_active_timer_endpoint(request: Request) -> dict:
user_id = _resolve_user_id_from_request(request)
return get_active_timer(user_id)
@router.get("/api/v1/notifications")
async def get_notifications_endpoint(request: Request, limit: int = 20) -> dict:
user_id = _resolve_user_id_from_request(request)
return get_notifications(user_id, limit=limit)
@router.websocket("/api/v1/bottom-bar/ws")
async def bottom_bar_ws(websocket: WebSocket):
payload = _resolve_ws_payload(websocket)
if not payload:
await websocket.close(code=1008)
return
try:
user_id = int(payload.get("sub")) if payload.get("sub") is not None else None
except (TypeError, ValueError):
await websocket.close(code=1008)
return
await websocket.accept()
initial_status = get_dashboard_status()
initial_notifications = get_notifications(user_id, limit=20)
await websocket.send_json({"event": "status_delta", "data": initial_status})
await websocket.send_json({"event": "notification_delta", "data": initial_notifications})
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
last_timer_elapsed = -1
status_tick = 0
try:
while True:
timer = get_active_timer(user_id)
elapsed = int(timer.get("elapsed") or 0)
if elapsed != last_timer_elapsed:
await websocket.send_json({"event": "timer_tick", "data": timer})
last_timer_elapsed = elapsed
status_tick += 1
if status_tick >= 5:
status = get_dashboard_status()
notifications = get_notifications(user_id, limit=20)
status_json = json.dumps(status, sort_keys=True, default=str)
if status_json != last_status_json:
await websocket.send_json({"event": "status_delta", "data": status})
last_status_json = status_json
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
if notifications_json != last_notifications_json:
await websocket.send_json({"event": "notification_delta", "data": notifications})
last_notifications_json = notifications_json
status_tick = 0
try:
await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
except TimeoutError:
continue
except WebSocketDisconnect:
logger.info("Bottom bar websocket disconnected user_id=%s", user_id)
except Exception as exc:
logger.warning("Bottom bar websocket error user_id=%s error=%s", user_id, exc)

View File

@ -1,762 +0,0 @@
from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single, execute_update
from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases
router = APIRouter()
logger = logging.getLogger(__name__)
_USER_NOTES_SCHEMA_READY = False
class BossAssignPayload(BaseModel):
case_id: int
assignee_user_id: int
class BossAssignNextPayload(BaseModel):
assignee_user_id: int
class UserNoteCreatePayload(BaseModel):
title: Optional[str] = None
content: str
is_pinned: bool = False
class UserNoteUpdatePayload(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
is_pinned: Optional[bool] = None
is_archived: Optional[bool] = None
class NoteToCaseCommentPayload(BaseModel):
sag_id: int
excerpt: Optional[str] = None
class NoteToContactPayload(BaseModel):
contact_id: int
field: str
value: Optional[str] = None
excerpt: Optional[str] = None
mode: str = "append"
class NoteToCustomerPayload(BaseModel):
customer_id: int
field: str
value: Optional[str] = None
excerpt: Optional[str] = None
mode: str = "append"
def _ensure_user_notes_schema() -> None:
global _USER_NOTES_SCHEMA_READY
if _USER_NOTES_SCHEMA_READY:
return
exists = execute_query_single("SELECT to_regclass('public.user_notes') AS table_name") or {}
if exists.get("table_name"):
_USER_NOTES_SCHEMA_READY = True
return
execute_query(
"""
CREATE TABLE IF NOT EXISTS user_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL DEFAULT '',
content TEXT NOT NULL,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
)
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_user_notes_user_active
ON user_notes (user_id, is_archived, is_pinned, updated_at DESC)
WHERE deleted_at IS NULL
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated
ON user_notes (user_id, updated_at DESC)
WHERE deleted_at IS NULL
"""
)
execute_query(
"""
CREATE OR REPLACE FUNCTION update_user_notes_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
execute_query("DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes")
execute_query(
"""
CREATE TRIGGER trg_user_notes_updated_at
BEFORE UPDATE ON user_notes
FOR EACH ROW
EXECUTE FUNCTION update_user_notes_updated_at()
"""
)
_USER_NOTES_SCHEMA_READY = True
logger.warning("⚠️ user_notes table was missing and has been created automatically")
def _resolve_current_user_display_name(current_user: dict) -> str:
current_user_id = current_user.get("id")
if current_user_id is None:
return "System"
row = execute_query_single(
"""
SELECT full_name, username
FROM users
WHERE user_id = %s
""",
(int(current_user_id),),
) or {}
return str(row.get("full_name") or row.get("username") or f"Bruger #{current_user_id}")
def _get_owned_note_or_404(note_id: int, user_id: int) -> dict:
_ensure_user_notes_schema()
row = execute_query_single(
"""
SELECT id, user_id, title, content, is_pinned, is_archived, created_at, updated_at
FROM user_notes
WHERE id = %s
AND user_id = %s
AND deleted_at IS NULL
""",
(int(note_id), int(user_id)),
)
if not row:
raise HTTPException(status_code=404, detail="Note ikke fundet")
return row
def _normalize_note_text(value: Optional[str]) -> str:
return str(value or "").strip()
def _build_merge_value(current_value: Optional[str], incoming_value: str, mode: str) -> str:
incoming = _normalize_note_text(incoming_value)
if not incoming:
return str(current_value or "")
current = str(current_value or "").strip()
normalized_mode = str(mode or "append").strip().lower()
if normalized_mode == "replace":
return incoming
if not current:
return incoming
if incoming in current:
return current
return f"{current}\n{incoming}"
@router.get("/notes")
@router.get("/notes/")
async def list_user_notes(
include_archived: bool = Query(default=False),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
rows = execute_query(
"""
SELECT id, title, content, is_pinned, is_archived, created_at, updated_at
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND (%s = TRUE OR is_archived = FALSE)
ORDER BY is_pinned DESC, updated_at DESC, id DESC
LIMIT %s OFFSET %s
""",
(int(current_user_id), bool(include_archived), int(limit), int(offset)),
) or []
total_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND (%s = TRUE OR is_archived = FALSE)
""",
(int(current_user_id), bool(include_archived)),
) or {}
return {
"items": rows,
"count": int(total_row.get("count") or 0),
}
@router.post("/notes")
@router.post("/notes/")
async def create_user_note(payload: UserNoteCreatePayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
content = _normalize_note_text(payload.content)
if not content:
raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt")
title = _normalize_note_text(payload.title)
row = execute_query_single(
"""
INSERT INTO user_notes (user_id, title, content, is_pinned)
VALUES (%s, %s, %s, %s)
RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at
""",
(int(current_user_id), title, content, bool(payload.is_pinned)),
)
return row or {}
@router.patch("/notes/{note_id}")
@router.patch("/notes/{note_id}/")
async def update_user_note(note_id: int, payload: UserNoteUpdatePayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
_get_owned_note_or_404(note_id, int(current_user_id))
sets = []
params = []
if payload.title is not None:
sets.append("title = %s")
params.append(_normalize_note_text(payload.title))
if payload.content is not None:
content = _normalize_note_text(payload.content)
if not content:
raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt")
sets.append("content = %s")
params.append(content)
if payload.is_pinned is not None:
sets.append("is_pinned = %s")
params.append(bool(payload.is_pinned))
if payload.is_archived is not None:
sets.append("is_archived = %s")
params.append(bool(payload.is_archived))
if not sets:
return _get_owned_note_or_404(note_id, int(current_user_id))
params.extend([int(note_id), int(current_user_id)])
row = execute_query_single(
f"""
UPDATE user_notes
SET {', '.join(sets)}
WHERE id = %s
AND user_id = %s
AND deleted_at IS NULL
RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at
""",
tuple(params),
)
if not row:
raise HTTPException(status_code=404, detail="Note ikke fundet")
return row
@router.delete("/notes/{note_id}")
@router.delete("/notes/{note_id}/")
async def delete_user_note(note_id: int, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
_ensure_user_notes_schema()
deleted = execute_update(
"""
UPDATE user_notes
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = %s
AND user_id = %s
AND deleted_at IS NULL
""",
(int(note_id), int(current_user_id)),
)
if not deleted:
raise HTTPException(status_code=404, detail="Note ikke fundet")
return {"status": "deleted", "note_id": int(note_id)}
@router.post("/notes/{note_id}/actions/sag-comment")
@router.post("/notes/{note_id}/actions/sag-comment/")
async def note_to_case_comment(note_id: int, payload: NoteToCaseCommentPayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
note = _get_owned_note_or_404(note_id, int(current_user_id))
case_row = execute_query_single(
"""
SELECT id
FROM sag_sager
WHERE id = %s
AND deleted_at IS NULL
""",
(int(payload.sag_id),),
)
if not case_row:
raise HTTPException(status_code=404, detail="Sag ikke fundet")
text = _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
if not text:
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
author = _resolve_current_user_display_name(current_user)
created = execute_query_single(
"""
INSERT INTO sag_kommentarer (sag_id, indhold, forfatter)
VALUES (%s, %s, %s)
RETURNING id, sag_id, indhold, forfatter, created_at
""",
(int(payload.sag_id), text, author),
) or {}
return {
"status": "inserted",
"target": "sag_comment",
"note_id": int(note_id),
"sag_id": int(payload.sag_id),
"comment": created,
}
@router.post("/notes/{note_id}/actions/contact-update")
@router.post("/notes/{note_id}/actions/contact-update/")
async def note_to_contact_update(note_id: int, payload: NoteToContactPayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
note = _get_owned_note_or_404(note_id, int(current_user_id))
allowed_fields = {"phone", "mobile", "email", "title", "department"}
field = str(payload.field or "").strip().lower()
if field not in allowed_fields:
raise HTTPException(status_code=400, detail="Ugyldigt kontaktfelt")
contact = execute_query_single(
f"SELECT id, {field} FROM contacts WHERE id = %s",
(int(payload.contact_id),),
)
if not contact:
raise HTTPException(status_code=404, detail="Kontakt ikke fundet")
incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
if not incoming:
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
merged = _build_merge_value(contact.get(field), incoming, payload.mode)
updated = execute_query_single(
f"""
UPDATE contacts
SET {field} = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, {field}
""",
(merged, int(payload.contact_id)),
) or {}
return {
"status": "updated",
"target": "contact",
"note_id": int(note_id),
"contact_id": int(payload.contact_id),
"field": field,
"value": updated.get(field),
}
@router.post("/notes/{note_id}/actions/customer-update")
@router.post("/notes/{note_id}/actions/customer-update/")
async def note_to_customer_update(note_id: int, payload: NoteToCustomerPayload, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
note = _get_owned_note_or_404(note_id, int(current_user_id))
field = str(payload.field or "").strip().lower()
allowed_fields = {"phone", "mobile_phone", "email", "address", "invoice_email", "note"}
if field not in allowed_fields:
raise HTTPException(status_code=400, detail="Ugyldigt firmafelt")
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s",
(int(payload.customer_id),),
)
if not customer:
raise HTTPException(status_code=404, detail="Firma ikke fundet")
incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
if not incoming:
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
if field == "note":
author = _resolve_current_user_display_name(current_user)
created = execute_query_single(
"""
INSERT INTO customer_notes (customer_id, note_type, note, created_by)
VALUES (%s, %s, %s, %s)
RETURNING id, customer_id, note_type, note, created_by, created_at
""",
(int(payload.customer_id), "general", incoming, author),
) or {}
return {
"status": "inserted",
"target": "customer_note",
"note_id": int(note_id),
"customer_id": int(payload.customer_id),
"record": created,
}
current = execute_query_single(
f"SELECT {field} FROM customers WHERE id = %s",
(int(payload.customer_id),),
) or {}
merged = _build_merge_value(current.get(field), incoming, payload.mode)
updated = execute_query_single(
f"""
UPDATE customers
SET {field} = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, {field}
""",
(merged, int(payload.customer_id)),
) or {}
return {
"status": "updated",
"target": "customer",
"note_id": int(note_id),
"customer_id": int(payload.customer_id),
"field": field,
"value": updated.get(field),
}
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not None:
try:
return int(state_user_id)
except (TypeError, ValueError):
pass
user_id_param = request.query_params.get("user_id")
if user_id_param:
try:
return int(user_id_param)
except (TypeError, ValueError):
pass
token = (request.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(token) if token else None
sub_claim = payload.get("sub") if payload else None
if sub_claim is not None:
try:
return int(sub_claim)
except (TypeError, ValueError):
return None
return None
@router.get("/state")
async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = int(current_user_id)
force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin"))
context_path = request.query_params.get("context") or ""
return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access)
@router.get("/timers/own")
async def get_own_timers(
paused_limit: int = Query(default=10, ge=1, le=25),
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return get_own_timer_snapshot(int(current_user_id), paused_limit=paused_limit)
@router.get("/boss/unassigned-cases")
async def list_unassigned_open_cases(
limit: int = Query(default=25, ge=1, le=100),
current_user: dict = Depends(get_current_user),
):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
return get_unassigned_open_cases(limit=limit)
from app.services.task_routing import TaskRouter
from app.services.m365_calendar import M365CalendarService
def _has_boss_access(current_user: dict) -> bool:
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
return True
current_user_id = current_user.get("id")
if current_user_id is None:
return False
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(int(current_user_id),),
) or []
names = [str(r.get("name") or "") for r in rows]
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
return any(any(token in name for token in tokens) for name in names)
def _ensure_user_exists(user_id: int) -> None:
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
if not user:
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
def _get_next_unassigned_case() -> Optional[dict]:
return execute_query_single(
"""
SELECT id, titel, priority
FROM sag_sager
WHERE deleted_at IS NULL
AND ansvarlig_bruger_id IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY
CASE
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1
ELSE 2
END,
COALESCE(updated_at, created_at) ASC,
id ASC
LIMIT 1
"""
)
@router.post("/next_task")
async def assign_next_task(
request: Request,
user_id: int | None = Query(default=None),
current_user: dict = Depends(get_current_user),
):
# Prefer authenticated user context; allow explicit user_id for controlled testing.
current_user_id = current_user.get("id")
resolved_user_id = user_id
if resolved_user_id is None and current_user_id is not None:
resolved_user_id = int(current_user_id)
if resolved_user_id is None:
raise HTTPException(status_code=401, detail="Authentication required for task assignment")
# Kombinerer de nye services
router_svc = TaskRouter()
cal = M365CalendarService()
# Henter hvor meget fri tid medarbejderen har lige nu
free_mins = await cal.get_user_free_time("now", 2)
# Bed the engine allocate the next best task
task = await router_svc.get_next_best_task(resolved_user_id)
task = task or {}
return {
"status": "assigned",
"task": task,
"free_time_calculated": free_mins,
"message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). "
}
@router.post("/boss/auto-assign-next")
async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
next_case = _get_next_unassigned_case()
if not next_case:
return {
"status": "noop",
"message": "Ingen ufordelte åbne sager at fordele.",
}
assignee = execute_query_single(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
FROM users u
JOIN user_groups ug ON ug.user_id = u.user_id
JOIN groups g ON g.id = ug.group_id
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
WHERE LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%'])
GROUP BY u.user_id, u.full_name, u.username
ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC
LIMIT 1
"""
)
if not assignee:
raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling")
updated = execute_query_single(
"""
UPDATE sag_sager
SET ansvarlig_bruger_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, titel, priority, ansvarlig_bruger_id
""",
(int(assignee["user_id"]), int(next_case["id"])),
)
if not updated:
raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag")
return {
"status": "assigned",
"message": "Sagen blev auto-fordelt.",
"case": {
"id": updated.get("id"),
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
"priority": updated.get("priority") or "normal",
},
"assignee": {
"user_id": assignee.get("user_id"),
"name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}",
},
}
@router.post("/boss/assign-case")
async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
_ensure_user_exists(int(payload.assignee_user_id))
case_row = execute_query_single(
"""
SELECT id, titel, priority
FROM sag_sager
WHERE id = %s
AND deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
""",
(int(payload.case_id),),
)
if not case_row:
raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet")
updated = execute_query_single(
"""
UPDATE sag_sager
SET ansvarlig_bruger_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, titel, priority, ansvarlig_bruger_id
""",
(int(payload.assignee_user_id), int(payload.case_id)),
)
if not updated:
raise HTTPException(status_code=500, detail="Kunne ikke tildele sag")
return {
"status": "assigned",
"message": "Sagen blev tildelt.",
"case": {
"id": updated.get("id"),
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
"priority": updated.get("priority") or "normal",
},
"assignee_user_id": int(payload.assignee_user_id),
}
@router.post("/boss/assign-next-to-user")
async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)):
if not _has_boss_access(current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
_ensure_user_exists(int(payload.assignee_user_id))
next_case = _get_next_unassigned_case()
if not next_case:
return {
"status": "noop",
"message": "Ingen ufordelte åbne sager at tildele.",
}
updated = execute_query_single(
"""
UPDATE sag_sager
SET ansvarlig_bruger_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, titel, priority, ansvarlig_bruger_id
""",
(int(payload.assignee_user_id), int(next_case["id"])),
)
if not updated:
raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag")
return {
"status": "assigned",
"message": "Næste ufordelte sag blev tildelt.",
"case": {
"id": updated.get("id"),
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
"priority": updated.get("priority") or "normal",
},
"assignee_user_id": int(payload.assignee_user_id),
}

View File

@ -1,970 +0,0 @@
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
def _safe_count(row: Optional[dict], key: str = "count") -> int:
if not row:
return 0
try:
return int(row.get(key) or 0)
except (TypeError, ValueError):
return 0
def _format_elapsed(seconds: int) -> str:
total = max(0, int(seconds or 0))
hours = total // 3600
minutes = (total % 3600) // 60
secs = total % 60
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
def _priority_rank(priority: str) -> int:
normalized = str(priority or "").strip().lower()
if normalized in {"urgent", "critical", "kritisk"}:
return 3
if normalized in {"high", "høj"}:
return 2
if normalized in {"normal", "medium", "middel"}:
return 1
return 0
def _table_exists(table_name: str) -> bool:
row = execute_query_single(
"""
SELECT EXISTS(
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS exists
""",
(table_name,),
)
return bool((row or {}).get("exists"))
def _table_columns(table_name: str) -> List[str]:
rows = execute_query(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
""",
(table_name,),
) or []
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
if user_id is None:
return []
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,),
) or []
return [str(r.get("name") or "").strip() for r in rows if r.get("name")]
def _can_view_boss_tab(user_id: Optional[int]) -> bool:
if user_id is None:
return False
group_names = _get_user_group_names(user_id)
if not group_names:
# Fail-open for authenticated users if group mapping is missing.
return True
leadership_tokens = (
"admin",
"manager",
"leder",
"chef",
"teknik",
"technician",
"support",
"drift",
"it",
)
return any(
any(token in group for token in leadership_tokens)
for group in group_names
)
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
setting_value = str((setting or {}).get("value") or "").strip().lower()
if setting_value not in {"1", "true", "yes", "on"}:
return False
if user_id is None:
return True
pref = execute_query_single(
"""
SELECT enabled
FROM user_module_preferences
WHERE user_id = %s AND module_name = %s
LIMIT 1
""",
(user_id, "bottom_bar"),
)
if pref and pref.get("enabled") is not None:
return bool(pref.get("enabled"))
role = execute_query_single(
"""
SELECT mrs.enabled
FROM module_role_settings mrs
JOIN user_groups ug ON ug.group_id = mrs.group_id
WHERE ug.user_id = %s
AND mrs.module_name = %s
ORDER BY mrs.enabled DESC
LIMIT 1
""",
(user_id, "bottom_bar"),
)
if role and role.get("enabled") is not None:
return bool(role.get("enabled"))
return True
def get_dashboard_status() -> Dict[str, int]:
mails_unread = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM email_messages
WHERE deleted_at IS NULL
AND COALESCE(is_read, FALSE) = FALSE
"""
)
)
sager_open = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
"""
)
)
sager_urgent = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
"""
)
)
sager_unassigned = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND ansvarlig_bruger_id IS NULL
"""
)
)
return {
"mails_unread": mails_unread,
"sager_open": sager_open,
"sager_urgent": sager_urgent,
"sager_unassigned": sager_unassigned,
}
def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
if user_id is None:
return {
"active": False,
"sag_id": None,
"sag_navn": None,
"start_tid": None,
"elapsed": 0,
"elapsed_hhmmss": "00:00:00",
"time_entry_id": None,
}
timer = execute_query_single(
"""
SELECT
t.id,
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = TRUE
AND t.slut_tid IS NULL
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
LIMIT 1
""",
(user_id,),
)
if not timer:
return {
"active": False,
"sag_id": None,
"sag_navn": None,
"start_tid": None,
"elapsed": 0,
"elapsed_hhmmss": "00:00:00",
"time_entry_id": None,
}
elapsed = int(timer.get("elapsed") or 0)
return {
"active": True,
"sag_id": timer.get("sag_id"),
"sag_navn": timer.get("sag_navn"),
"start_tid": timer.get("start_tid"),
"elapsed": elapsed,
"elapsed_hhmmss": _format_elapsed(elapsed),
"time_entry_id": timer.get("id"),
}
def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Dict[str, Any]:
active = get_active_timer(user_id)
if user_id is None:
return {
"active": active,
"paused": [],
"counts": {"active": 0, "paused": 0, "total": 0},
}
paused_limit_safe = max(1, min(int(paused_limit or 10), 25))
paused_rows = execute_query(
"""
SELECT
t.id,
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
t.slut_tid,
GREATEST(
EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int,
0
) AS elapsed_seconds,
COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = FALSE
AND t.paused_at IS NOT NULL
AND t.slut_tid IS NULL
ORDER BY COALESCE(t.paused_at, t.updated_at, t.created_at) DESC, t.id DESC
LIMIT %s
""",
(user_id, paused_limit_safe),
) or []
paused_count_row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM tmodule_times t
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = FALSE
AND t.paused_at IS NOT NULL
AND t.slut_tid IS NULL
""",
(user_id,),
)
paused_count = _safe_count(paused_count_row)
active_count = 1 if active.get("active") else 0
return {
"active": active,
"paused": [
{
"time_entry_id": row.get("id"),
"sag_id": row.get("sag_id"),
"sag_navn": row.get("sag_navn") or f"Sag #{row.get('sag_id')}",
"start_tid": row.get("start_tid"),
"slut_tid": row.get("slut_tid"),
"faktisk_tid_min": 0,
"elapsed_hhmmss": _format_elapsed(
max(0, int(row.get("elapsed_seconds") or 0) - int(row.get("pause_total_seconds") or 0))
),
}
for row in paused_rows
],
"counts": {
"active": active_count,
"paused": paused_count,
"total": active_count + paused_count,
},
}
def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 25), 100))
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.created_at,
s.updated_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
(limit_safe,),
) or []
count_row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
"""
)
return {
"items": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in rows
],
"count": _safe_count(count_row),
"filter_meta": {
"route": "/api/v1/bottom-bar/boss/unassigned-cases",
"query": {"limit": limit_safe, "only_open": True, "only_unassigned": True},
"sql_guarantee": [
"s.deleted_at IS NULL",
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
"s.ansvarlig_bruger_id IS NULL",
],
},
}
def _get_recent_cases(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 10), 20))
source = "direct_query"
rows: List[Dict[str, Any]] = []
if _table_exists("sag_recent_cases"):
columns = set(_table_columns("sag_recent_cases"))
has_required = {"sag_id", "user_id"}.issubset(columns)
if has_required:
order_column = "viewed_at" if "viewed_at" in columns else "opened_at" if "opened_at" in columns else "updated_at" if "updated_at" in columns else "created_at"
if order_column:
source = "sag_recent_cases"
rows = execute_query(
f"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
rc.{order_column} AS recent_at
FROM sag_recent_cases rc
JOIN sag_sager s ON s.id = rc.sag_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND rc.user_id = %s
ORDER BY rc.{order_column} DESC, s.id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
if not rows and user_id is not None:
source = "direct_query_user_timers"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
MAX(COALESCE(t.start_tid, t.updated_at, t.created_at)) AS recent_at
FROM tmodule_times t
JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
GROUP BY s.id, s.titel, s.priority, s.status
ORDER BY recent_at DESC, s.id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
if not rows:
source = "direct_query_global"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
COALESCE(s.updated_at, s.created_at) AS recent_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
(limit_safe,),
) or []
return {
"source": source,
"items": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"status": row.get("status"),
"recent_at": row.get("recent_at"),
}
for row in rows
],
"count": len(rows),
}
def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
if user_id is None:
return {"items": [], "count": 0}
limit_safe = max(1, min(int(limit or 20), 100))
reminders = execute_query(
"""
SELECT
r.id,
r.sag_id,
r.title,
r.message,
r.priority,
r.event_type,
r.next_check_at,
s.titel AS case_title,
c.name AS customer_name
FROM sag_reminders r
JOIN sag_sager s ON r.sag_id = s.id
JOIN customers c ON s.customer_id = c.id
LEFT JOIN LATERAL (
SELECT id, snoozed_until, status, triggered_at
FROM sag_reminder_logs
WHERE reminder_id = r.id AND user_id = %s
ORDER BY triggered_at DESC
LIMIT 1
) l ON true
WHERE r.is_active = TRUE
AND r.deleted_at IS NULL
AND r.next_check_at <= CURRENT_TIMESTAMP
AND %s = ANY(r.recipient_user_ids)
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
AND (l.status IS NULL OR l.status != 'dismissed')
ORDER BY
CASE LOWER(COALESCE(r.priority::text, 'normal'))
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
ELSE 4
END,
r.next_check_at ASC
LIMIT %s
""",
(user_id, user_id, limit_safe),
) or []
unread_mail_count = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM email_messages em
WHERE em.deleted_at IS NULL
AND COALESCE(em.is_read, FALSE) = FALSE
"""
)
)
items: List[Dict[str, Any]] = []
if unread_mail_count > 0:
items.append(
{
"id": f"mail-unread-{unread_mail_count}",
"type": "mail",
"severity": "medium" if unread_mail_count < 10 else "high",
"title": f"{unread_mail_count} ulæste mails",
"message": "Der er ulæste mails i indbakken",
"action": "/emails",
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
for row in reminders:
priority = str(row.get("priority") or "normal").lower()
severity = "low"
if priority in {"high", "høj"}:
severity = "medium"
if priority in {"urgent", "critical", "kritisk"}:
severity = "high"
items.append(
{
"id": f"reminder-{row.get('id')}",
"type": row.get("event_type") or "reminder",
"severity": severity,
"title": row.get("title") or "Påmindelse",
"message": row.get("message") or row.get("case_title") or "",
"sag_id": row.get("sag_id"),
"case_title": row.get("case_title"),
"customer_name": row.get("customer_name"),
"action": f"/sag/{row.get('sag_id')}/v3" if row.get("sag_id") else "/sag",
"created_at": row.get("next_check_at"),
}
)
items.sort(
key=lambda item: (
{"high": 0, "medium": 1, "low": 2}.get(str(item.get("severity") or "low"), 3),
str(item.get("created_at") or ""),
)
)
return {"items": items[:limit_safe], "count": len(items)}
def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
normalized = str(context_path or "").strip().lower()
payload: Dict[str, Any] = {
"context_key": "global",
"global": [
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
],
"context": [],
}
if normalized.startswith("/sag"):
payload["context_key"] = "sag"
payload["context"] = [
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
{"id": "case_mail", "label": "Mail", "action": "/emails"},
{"id": "case_relation", "label": "Relation", "action": "/customers"},
{"id": "case_tag", "label": "Tag", "action": "/tags"},
]
elif normalized.startswith("/hardware"):
payload["context_key"] = "hardware"
payload["context"] = [
{"id": "hardware_new", "label": "Ny enhed", "action": "/hardware"},
{"id": "hardware_history", "label": "Historik", "action": "/hardware"},
{"id": "hardware_link_case", "label": "Tilknyt sag", "action": "/sag"},
]
return payload
def get_user_notes_summary(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
if user_id is None:
return {"count": 0, "list": []}
limit_safe = max(1, min(int(limit or 10), 50))
rows = execute_query(
"""
SELECT
id,
title,
content,
is_pinned,
is_archived,
created_at,
updated_at
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND is_archived = FALSE
ORDER BY is_pinned DESC, updated_at DESC, id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
total_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND is_archived = FALSE
""",
(user_id,),
)
return {
"count": _safe_count(total_row),
"list": [
{
"id": row.get("id"),
"title": row.get("title") or "",
"content": row.get("content") or "",
"is_pinned": bool(row.get("is_pinned")),
"is_archived": bool(row.get("is_archived")),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in rows
],
}
def build_bottom_bar_state(
user_id: Optional[int],
context_path: str = "",
force_boss_access: bool = False,
) -> Dict[str, Any]:
enabled = is_bottom_bar_enabled(user_id)
if not enabled:
return {"enabled": False, "sections": {}}
status = get_dashboard_status()
timer = get_active_timer(user_id)
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
notifications = get_notifications(user_id, limit=10)
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
urgent_cases = execute_query(
"""
SELECT id, titel
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
) or []
open_cases = execute_query(
"""
SELECT id, titel
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
) or []
timer_list: List[Dict[str, Any]] = []
if timer.get("active"):
timer_list.append(
{
"id": timer.get("time_entry_id"),
"sag_id": timer.get("sag_id"),
"desc": timer.get("sag_navn") or f"Sag #{timer.get('sag_id')}",
"elapsed": timer.get("elapsed"),
"elapsed_hhmmss": timer.get("elapsed_hhmmss"),
}
)
messages = [
{
"from": "System",
"text": f"{notifications.get('count', 0)} aktive notifikationer",
}
]
tasks = []
for n in (notifications.get("items") or [])[:5]:
tasks.append(
{
"title": n.get("title") or "Notifikation",
"deadline": n.get("severity") or "info",
"action": n.get("action") or "/",
}
)
context_actions = _context_actions_for_path(context_path)
can_view_boss = bool(force_boss_access) or _can_view_boss_tab(user_id)
team_workload: List[Dict[str, Any]] = []
technicians_today: List[Dict[str, Any]] = []
escalation_cases: List[Dict[str, Any]] = []
unassigned_cases: List[Dict[str, Any]] = []
if can_view_boss:
team_workload = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
GROUP BY u.user_id, u.full_name, u.username
HAVING COUNT(s.id) > 0
ORDER BY urgent_cases DESC, open_cases DESC, owner_name ASC
LIMIT 8
"""
) or []
technicians_today = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN s.deadline::date = CURRENT_DATE THEN 1 END)::int AS due_today_cases,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', t.id,
'title', t.titel,
'priority', COALESCE(t.priority::text, 'normal'),
'deadline', t.deadline
)
ORDER BY
CASE WHEN t.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
COALESCE(t.deadline, t.updated_at, t.created_at) ASC,
t.id ASC
)
FROM (
SELECT s2.id, s2.titel, s2.priority, s2.deadline, s2.updated_at, s2.created_at
FROM sag_sager s2
WHERE s2.ansvarlig_bruger_id = u.user_id
AND s2.deleted_at IS NULL
AND LOWER(COALESCE(s2.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND (
s2.deadline::date = CURRENT_DATE
OR s2.created_at::date = CURRENT_DATE
)
ORDER BY
CASE WHEN s2.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
COALESCE(s2.deadline, s2.updated_at, s2.created_at) ASC,
s2.id ASC
LIMIT 6
) t
),
'[]'::json
) AS today_tasks
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
WHERE EXISTS (
SELECT 1
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.user_id
AND LOWER(g.name) LIKE ANY(ARRAY['%teknik%', '%technician%', '%support%'])
)
GROUP BY u.user_id, u.full_name, u.username
ORDER BY due_today_cases DESC, open_cases DESC, owner_name ASC
LIMIT 10
"""
) or []
escalation_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.updated_at,
EXTRACT(EPOCH FROM (NOW() - COALESCE(s.updated_at, s.created_at)))::int AS age_seconds,
COALESCE(NULLIF(u.full_name, ''), u.username, 'Ikke tildelt') AS owner_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
LIMIT 8
"""
) or []
unassigned_cases = [
{
"id": row.get("id"),
"titel": row.get("title"),
"priority": row.get("priority"),
}
for row in (unassigned_open_cases.get("items") or [])
]
sections = {
"mail": {
"unread": status.get("mails_unread", 0),
"customer_reply_needed": status.get("mails_unread", 0),
},
"cases": {
"open": status.get("sager_open", 0),
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in open_cases],
},
"urgent": {
"count": status.get("sager_urgent", 0),
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
},
"unassigned": {
"count": status.get("sager_unassigned", 0),
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
},
"timer": {
"active_count": 1 if timer.get("active") else 0,
"list": timer_list,
"active": timer,
"own": own_timers,
"switch_case_hooks": {
"fetch_own_active_paused_timers": {
"route": "/api/v1/bottom-bar/timers/own",
"method": "GET",
"query": {"paused_limit": 10},
},
"switch_case_start_timer": {
"route": "/api/v1/timetracking/time/start",
"method": "POST",
"payload": {
"sag_id": "required:int",
"medarbejder_id": "optional:int",
"beskrivelse": "optional:string",
},
},
},
},
"kuma": {
"down": 0,
"list": [],
},
"eset": {
"incidents": 0,
"list": [],
},
"messages": {
"count": len(messages),
"list": messages,
},
"tasks": {
"count": len(tasks),
"list": tasks,
},
"recent_cases": recent_cases,
"notes": notes_summary,
"boss": {
"can_view": can_view_boss,
"stats": {
"unassigned": status.get("sager_unassigned", 0),
"active_employees": _safe_count(
execute_query_single(
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"
)
),
"open_cases": status.get("sager_open", 0),
"urgent_cases": status.get("sager_urgent", 0),
"stale_urgent_cases": len(escalation_cases),
}
,
"team_workload": [
{
"user_id": row.get("user_id"),
"owner_name": row.get("owner_name"),
"open_cases": int(row.get("open_cases") or 0),
"urgent_cases": int(row.get("urgent_cases") or 0),
}
for row in team_workload
],
"technicians_today": [
{
"user_id": row.get("user_id"),
"owner_name": row.get("owner_name"),
"open_cases": int(row.get("open_cases") or 0),
"due_today_cases": int(row.get("due_today_cases") or 0),
"today_tasks": row.get("today_tasks") or [],
}
for row in technicians_today
],
"escalations": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"owner_name": row.get("owner_name") or "Ikke tildelt",
"age_seconds": int(row.get("age_seconds") or 0),
}
for row in escalation_cases
],
"unassigned_cases": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
}
for row in unassigned_cases
],
},
"context_actions": context_actions,
}
return {
"enabled": True,
"sections": sections,
"status": status,
"active_timer": timer,
"own_timers": own_timers,
"recent_cases": recent_cases,
"notifications": notifications,
}

View File

@ -1,11 +0,0 @@
{
"name": "bottom_bar",
"version": "1.0.0",
"description": "Global activity bottom bar module",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "bottom_bar_",
"api_prefix": "/api/v1",
"tags": ["Bottom Bar"]
}

View File

@ -127,7 +127,7 @@ def _get_calendar_events(
"case_deadline",
title,
start_value,
f"/sag/{row.get('id')}/v3",
f"/sag/{row.get('id')}",
{
"reference_id": row.get("id"),
"reference_type": "case",
@ -170,7 +170,7 @@ def _get_calendar_events(
"case_deferred",
title,
start_value,
f"/sag/{row.get('id')}/v3",
f"/sag/{row.get('id')}",
{
"reference_id": row.get("id"),
"reference_type": "case",
@ -224,7 +224,7 @@ def _get_calendar_events(
"case_reminder",
title,
start_value,
f"/sag/{row.get('sag_id')}/v3",
f"/sag/{row.get('sag_id')}",
{
"reference_id": row.get("id"),
"reference_type": "reminder",

View File

@ -1,87 +0,0 @@
import logging
from typing import Any, Dict, List
import aiohttp
from app.core.config import settings
logger = logging.getLogger(__name__)
class FedExApiClient:
def __init__(self) -> None:
self.base_url = (settings.FEDEX_BASE_URL or "").rstrip("/")
self.timeout_seconds = max(5, int(settings.FEDEX_TIMEOUT_SECONDS or 20))
def _headers(self) -> Dict[str, str]:
return {
"Content-Type": "application/json",
"X-API-KEY": settings.FEDEX_API_KEY or "",
"X-API-SECRET": settings.FEDEX_API_SECRET or "",
"X-FEDEX-ACCOUNT": settings.FEDEX_ACCOUNT_NUMBER or "",
}
async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
if not self.base_url:
raise RuntimeError("FedEx base URL is not configured")
url = f"{self.base_url}/shipments"
logger.info("🚀 FedEx create shipment request sent")
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers=self._headers()) as response:
body = await response.text()
if response.status >= 400:
logger.error("❌ FedEx create shipment failed (%s): %s", response.status, body)
raise RuntimeError(f"FedEx create shipment failed: HTTP {response.status}")
return await response.json()
async def get_tracking(self, tracking_number: str) -> Dict[str, Any]:
if not self.base_url:
raise RuntimeError("FedEx base URL is not configured")
url = f"{self.base_url}/tracking/{tracking_number}"
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=self._headers()) as response:
body = await response.text()
if response.status >= 400:
logger.error("❌ FedEx tracking failed (%s): %s", response.status, body)
raise RuntimeError(f"FedEx tracking failed: HTTP {response.status}")
return await response.json()
async def cancel_shipment(self, tracking_number: str) -> Dict[str, Any]:
if not self.base_url:
raise RuntimeError("FedEx base URL is not configured")
url = f"{self.base_url}/shipments/{tracking_number}/cancel"
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=self._headers()) as response:
body = await response.text()
if response.status >= 400:
logger.error("❌ FedEx cancel failed (%s): %s", response.status, body)
raise RuntimeError(f"FedEx cancel failed: HTTP {response.status}")
return await response.json()
def parse_tracking_events(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
raw_events = payload.get("events") or []
if not isinstance(raw_events, list):
return []
normalized: List[Dict[str, Any]] = []
for event in raw_events:
if not isinstance(event, dict):
continue
normalized.append(
{
"status": str(event.get("status") or "unknown"),
"description": event.get("description"),
"event_timestamp": event.get("event_timestamp") or event.get("timestamp"),
"location_city": event.get("location_city") or event.get("city"),
"location_country": event.get("location_country") or event.get("country"),
}
)
return normalized

View File

@ -1,66 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Query, Request
from app.modules.fedex.backend.service import fedex_service
from app.modules.fedex.models.schemas import (
FedExBookingCreate,
FedExBookingListResponse,
FedExBookingResponse,
FedExBookingSubmitResponse,
FedExCancelRequest,
FedExCancelResponse,
FedExTrackingResponse,
)
router = APIRouter()
def _user_id_from_request(request: Request) -> Optional[int]:
raw_user_id = getattr(request.state, "user_id", None)
if raw_user_id is None:
return None
try:
return int(raw_user_id)
except (TypeError, ValueError):
return None
@router.get("/fedex/config")
async def fedex_config() -> dict:
return {
"enabled": fedex_service.enabled,
"read_only": fedex_service.read_only,
"dry_run": fedex_service.dry_run,
}
@router.post("/fedex/bookings", response_model=FedExBookingResponse)
async def create_booking(payload: FedExBookingCreate, request: Request):
booking = fedex_service.create_booking_draft(payload, _user_id_from_request(request))
return booking
@router.get("/fedex/bookings", response_model=FedExBookingListResponse)
async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)):
return {"items": fedex_service.list_bookings(case_id=case_id)}
@router.get("/fedex/bookings/{booking_ref}", response_model=FedExBookingResponse)
async def get_booking(booking_ref: str):
return fedex_service.get_booking(booking_ref)
@router.post("/fedex/bookings/{booking_ref}/submit", response_model=FedExBookingSubmitResponse)
async def submit_booking(booking_ref: str, request: Request):
return await fedex_service.submit_booking(booking_ref, _user_id_from_request(request))
@router.get("/fedex/bookings/{booking_ref}/tracking", response_model=FedExTrackingResponse)
async def get_tracking(booking_ref: str):
return await fedex_service.get_tracking(booking_ref)
@router.post("/fedex/bookings/{booking_ref}/cancel", response_model=FedExCancelResponse)
async def cancel_booking(booking_ref: str, payload: FedExCancelRequest, request: Request):
return await fedex_service.cancel_booking(booking_ref, payload.reason, _user_id_from_request(request))

View File

@ -1,675 +0,0 @@
import json
import logging
from decimal import Decimal
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import uuid4
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_query_single, table_has_column
from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events
from app.modules.fedex.models.schemas import FedExBookingCreate
logger = logging.getLogger(__name__)
def _json_default(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, default=_json_default)
def _to_float(value: Any) -> Optional[float]:
try:
if value is None or value == "":
return None
return float(value)
except (TypeError, ValueError):
return None
def _extract_price_info(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]:
if not isinstance(payload, dict):
return None, None
direct_amount = _to_float(
payload.get("total_amount")
or payload.get("totalAmount")
or payload.get("total_cost")
or payload.get("totalCost")
or payload.get("price")
or payload.get("amount")
)
direct_currency = (
payload.get("currency")
or payload.get("currencyCode")
or payload.get("total_cost_currency")
)
if direct_amount is not None:
return direct_amount, str(direct_currency or "").upper() or None
stack: List[Any] = [payload]
visited: set[int] = set()
while stack:
node = stack.pop()
node_id = id(node)
if node_id in visited:
continue
visited.add(node_id)
if isinstance(node, dict):
amount = _to_float(node.get("amount") or node.get("value"))
currency = node.get("currency") or node.get("currencyCode")
if amount is not None and currency:
return amount, str(currency).upper()
# Prioritize common FedEx charge keys if present.
for key in (
"totalNetCharge",
"totalNetFedExCharge",
"totalBaseCharge",
"totalSurcharges",
"netCharge",
):
nested = node.get(key)
if isinstance(nested, dict):
nested_amount = _to_float(nested.get("amount") or nested.get("value"))
nested_currency = nested.get("currency") or nested.get("currencyCode")
if nested_amount is not None:
return nested_amount, str(nested_currency or "").upper() or None
stack.extend(node.values())
elif isinstance(node, list):
stack.extend(node)
return None, None
def _extract_label_url(payload: Dict[str, Any]) -> Optional[str]:
if not isinstance(payload, dict):
return None
direct = payload.get("label_url") or payload.get("labelUrl") or payload.get("label")
if isinstance(direct, str) and direct.strip().lower().startswith(("http://", "https://")):
return direct.strip()
stack: List[Any] = [payload]
visited: set[int] = set()
while stack:
node = stack.pop()
node_id = id(node)
if node_id in visited:
continue
visited.add(node_id)
if isinstance(node, dict):
for key, value in node.items():
key_lower = str(key).lower()
if isinstance(value, str):
v = value.strip()
if v.lower().startswith(("http://", "https://")) and (
"label" in key_lower or "document" in key_lower or "url" in key_lower
):
return v
elif isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return None
def _extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]:
if not isinstance(payload, dict):
return None
direct = (
payload.get("tracking_number")
or payload.get("trackingNumber")
or payload.get("masterTrackingNumber")
)
if direct is not None:
value = str(direct).strip()
if value:
return value
stack: List[Any] = [payload]
visited: set[int] = set()
while stack:
node = stack.pop()
node_id = id(node)
if node_id in visited:
continue
visited.add(node_id)
if isinstance(node, dict):
for key, value in node.items():
key_lower = str(key).lower()
if "tracking" in key_lower and value is not None and not isinstance(value, (dict, list)):
candidate = str(value).strip()
if candidate:
return candidate
if isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return None
def _build_tracking_url(tracking_number: Optional[str]) -> Optional[str]:
if not tracking_number:
return None
return f"https://www.fedex.com/fedextrack/?trknbr={tracking_number}"
def _estimate_dry_run_price(payload: Dict[str, Any]) -> tuple[float, str]:
packages = payload.get("packages") if isinstance(payload, dict) else []
if not isinstance(packages, list) or not packages:
return 99.0, "DKK"
total_weight = 0.0
for p in packages:
if isinstance(p, dict):
total_weight += _to_float(p.get("weight_kg")) or 0.0
estimated = round(79.0 + (total_weight * 8.5), 2)
return max(estimated, 79.0), "DKK"
class FedExService:
def __init__(self) -> None:
self.client = FedExApiClient()
@property
def enabled(self) -> bool:
return bool(settings.FEDEX_ENABLED)
@property
def read_only(self) -> bool:
return bool(settings.FEDEX_READ_ONLY)
@property
def dry_run(self) -> bool:
return bool(settings.FEDEX_DRY_RUN)
def _assert_enabled(self) -> None:
if not self.enabled:
raise HTTPException(status_code=503, detail="FedEx integration is disabled")
def _booking_ref(self) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return f"FDX-{stamp}-{uuid4().hex[:8].upper()}"
def _validate_relations(self, payload: FedExBookingCreate) -> None:
case_exists = execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,))
if not case_exists:
raise HTTPException(status_code=404, detail="Case not found")
if payload.customer_id:
customer_exists = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
if not customer_exists:
raise HTTPException(status_code=404, detail="Customer not found")
if payload.contact_id:
contact_exists = execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,))
if not contact_exists:
raise HTTPException(status_code=404, detail="Contact not found")
def _fetch_packages(self, shipment_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT weight_kg, length_cm, width_cm, height_cm, description
FROM fedex_shipment_packages
WHERE shipment_id = %s
ORDER BY id ASC
""",
(shipment_id,),
) or []
return [dict(row) for row in rows]
def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
mapped = dict(row)
mapped["packages"] = self._fetch_packages(int(row["id"]))
api_response = mapped.get("api_response")
if isinstance(api_response, str):
try:
api_response = json.loads(api_response)
except Exception:
api_response = None
if isinstance(api_response, dict):
if not mapped.get("tracking_number"):
mapped["tracking_number"] = _extract_tracking_number(api_response)
if not mapped.get("label_url"):
mapped["label_url"] = _extract_label_url(api_response)
if mapped.get("total_amount") is None:
fallback_amount, fallback_currency = _extract_price_info(api_response)
if fallback_amount is not None:
mapped["total_amount"] = fallback_amount
if not mapped.get("currency") and fallback_currency:
mapped["currency"] = fallback_currency
mapped["tracking_url"] = _build_tracking_url(mapped.get("tracking_number"))
# Ensure older dry-run rows still expose useful test outputs in UI.
if mapped.get("dry_run") and mapped.get("shipment_status") in {"submitted", "booked"}:
if not mapped.get("label_url") and mapped.get("tracking_url"):
mapped["label_url"] = mapped["tracking_url"]
if mapped.get("total_amount") is None:
estimated_amount, estimated_currency = _estimate_dry_run_price({"packages": mapped.get("packages") or []})
mapped["total_amount"] = estimated_amount
if not mapped.get("currency"):
mapped["currency"] = estimated_currency
return mapped
def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]:
self._assert_enabled()
self._validate_relations(payload)
if payload.pickup_window_end <= payload.pickup_window_start:
raise HTTPException(status_code=400, detail="pickup_window_end must be after pickup_window_start")
booking_ref = self._booking_ref()
shipment_row = execute_query_single(
"""
INSERT INTO fedex_shipments (
booking_ref,
case_id,
customer_id,
contact_id,
service_type,
shipment_status,
pickup_window_start,
pickup_window_end,
recipient_name,
company_name,
address_line1,
address_line2,
postal_code,
city,
country_code,
phone,
email,
dry_run,
created_by_user_id,
updated_by_user_id,
api_payload,
api_response
) VALUES (
%s, %s, %s, %s, %s, 'draft',
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s::jsonb, %s::jsonb
)
RETURNING *
""",
(
booking_ref,
payload.case_id,
payload.customer_id,
payload.contact_id,
payload.service_type,
payload.pickup_window_start,
payload.pickup_window_end,
payload.address.recipient_name,
payload.address.company_name,
payload.address.address_line1,
payload.address.address_line2,
payload.address.postal_code,
payload.address.city,
payload.address.country_code.upper(),
payload.address.phone,
payload.address.email,
self.dry_run,
created_by_user_id,
created_by_user_id,
_json_dumps(payload.model_dump(mode="json")),
_json_dumps({"status": "draft_created"}),
),
)
if not shipment_row:
raise HTTPException(status_code=500, detail="Failed to create booking draft")
shipment_id = int(shipment_row["id"])
for package in payload.packages:
execute_query(
"""
INSERT INTO fedex_shipment_packages (
shipment_id, weight_kg, length_cm, width_cm, height_cm, description
) VALUES (%s, %s, %s, %s, %s, %s)
""",
(
shipment_id,
package.weight_kg,
package.length_cm,
package.width_cm,
package.height_cm,
package.description,
),
)
logger.info("✅ FedEx draft created: %s (case=%s)", booking_ref, payload.case_id)
return self._shipment_row_to_dict(dict(shipment_row))
def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]:
params: List[Any] = []
where_sql = "WHERE deleted_at IS NULL"
if case_id is not None:
where_sql += " AND case_id = %s"
params.append(case_id)
rows = execute_query(
f"""
SELECT *
FROM fedex_shipments
{where_sql}
ORDER BY created_at DESC
LIMIT 200
""",
tuple(params),
) or []
return [self._shipment_row_to_dict(dict(row)) for row in rows]
def get_booking(self, booking_ref: str) -> Dict[str, Any]:
row = execute_query_single(
"""
SELECT *
FROM fedex_shipments
WHERE booking_ref = %s
AND deleted_at IS NULL
""",
(booking_ref,),
)
if not row:
raise HTTPException(status_code=404, detail="Booking not found")
return self._shipment_row_to_dict(dict(row))
async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]:
self._assert_enabled()
shipment = self.get_booking(booking_ref)
if shipment["shipment_status"] not in {"draft", "failed"}:
raise HTTPException(status_code=409, detail="Only draft/failed bookings can be submitted")
if self.read_only:
raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)")
payload = {
"booking_ref": shipment["booking_ref"],
"service_type": shipment["service_type"],
"pickup_window_start": shipment["pickup_window_start"].isoformat() if shipment.get("pickup_window_start") else None,
"pickup_window_end": shipment["pickup_window_end"].isoformat() if shipment.get("pickup_window_end") else None,
"recipient": {
"recipient_name": shipment["recipient_name"],
"company_name": shipment.get("company_name"),
"address_line1": shipment["address_line1"],
"address_line2": shipment.get("address_line2"),
"postal_code": shipment["postal_code"],
"city": shipment["city"],
"country_code": shipment["country_code"],
"phone": shipment.get("phone"),
"email": shipment.get("email"),
},
"packages": shipment["packages"],
}
if self.dry_run:
tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}"
label_url = _build_tracking_url(tracking_number)
total_amount, currency = _estimate_dry_run_price(payload)
api_response = {
"dry_run": True,
"tracking_number": tracking_number,
"label_url": label_url,
"total_amount": total_amount,
"currency": currency,
}
new_status = "submitted"
else:
try:
api_response = await self.client.create_shipment(payload)
except Exception as exc:
execute_query(
"""
UPDATE fedex_shipments
SET shipment_status = 'failed',
api_response = %s::jsonb,
updated_by_user_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE booking_ref = %s
""",
(_json_dumps({"error": str(exc)}), user_id, booking_ref),
)
raise HTTPException(status_code=502, detail="FedEx booking failed") from exc
tracking_number = _extract_tracking_number(api_response)
label_url = _extract_label_url(api_response)
new_status = "booked"
total_amount, currency = _extract_price_info(api_response)
has_total_amount = table_has_column("fedex_shipments", "total_amount")
has_currency = table_has_column("fedex_shipments", "currency")
set_clauses = [
"shipment_status = %s",
"tracking_number = COALESCE(%s, tracking_number)",
"label_url = COALESCE(%s, label_url)",
]
update_params: List[Any] = [
new_status,
tracking_number,
label_url,
]
if has_total_amount:
set_clauses.append("total_amount = COALESCE(%s, total_amount)")
update_params.append(total_amount)
if has_currency:
set_clauses.append("currency = COALESCE(%s, currency)")
update_params.append(currency)
set_clauses.extend([
"submitted_at = CURRENT_TIMESTAMP",
"api_payload = %s::jsonb",
"api_response = %s::jsonb",
"updated_by_user_id = %s",
"updated_at = CURRENT_TIMESTAMP",
])
update_params.extend([
_json_dumps(payload),
_json_dumps(api_response),
user_id,
booking_ref,
])
updated = execute_query_single(
f"""
UPDATE fedex_shipments
SET {', '.join(set_clauses)}
WHERE booking_ref = %s
RETURNING *
""",
tuple(update_params),
)
if tracking_number:
execute_query(
"""
INSERT INTO fedex_tracking_events (
shipment_id, status, description, event_timestamp
) VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
""",
(
updated["id"],
"submitted" if self.dry_run else "booked",
"Shipment submitted from BMC Hub",
),
)
return {
"booking_ref": booking_ref,
"status": new_status,
"dry_run": self.dry_run,
"tracking_number": tracking_number,
"tracking_url": _build_tracking_url(tracking_number),
"label_url": label_url,
"total_amount": total_amount,
"currency": currency,
}
async def get_tracking(self, booking_ref: str) -> Dict[str, Any]:
self._assert_enabled()
shipment = self.get_booking(booking_ref)
tracking_number = shipment.get("tracking_number")
if not tracking_number:
events = execute_query(
"""
SELECT status, event_timestamp, description, location_city, location_country
FROM fedex_tracking_events
WHERE shipment_id = %s
ORDER BY event_timestamp DESC
""",
(shipment["id"],),
) or []
return {
"booking_ref": booking_ref,
"shipment_status": shipment["shipment_status"],
"tracking_number": None,
"events": [dict(row) for row in events],
}
if self.dry_run:
events = execute_query(
"""
SELECT status, event_timestamp, description, location_city, location_country
FROM fedex_tracking_events
WHERE shipment_id = %s
ORDER BY event_timestamp DESC
""",
(shipment["id"],),
) or []
return {
"booking_ref": booking_ref,
"shipment_status": shipment["shipment_status"],
"tracking_number": tracking_number,
"events": [dict(row) for row in events],
}
try:
provider_payload = await self.client.get_tracking(tracking_number)
except Exception as exc:
raise HTTPException(status_code=502, detail="Failed to fetch FedEx tracking") from exc
events = parse_tracking_events(provider_payload)
if events:
execute_query(
"DELETE FROM fedex_tracking_events WHERE shipment_id = %s",
(shipment["id"],),
)
for event in events:
execute_query(
"""
INSERT INTO fedex_tracking_events (
shipment_id,
status,
description,
event_timestamp,
location_city,
location_country
) VALUES (
%s, %s, %s,
COALESCE(%s::timestamp, CURRENT_TIMESTAMP),
%s, %s
)
""",
(
shipment["id"],
event.get("status") or "unknown",
event.get("description"),
event.get("event_timestamp"),
event.get("location_city"),
event.get("location_country"),
),
)
status = str(provider_payload.get("shipment_status") or shipment["shipment_status"])
execute_query(
"""
UPDATE fedex_shipments
SET shipment_status = %s,
api_response = %s::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(status, _json_dumps(provider_payload), shipment["id"]),
)
current_events = execute_query(
"""
SELECT status, event_timestamp, description, location_city, location_country
FROM fedex_tracking_events
WHERE shipment_id = %s
ORDER BY event_timestamp DESC
""",
(shipment["id"],),
) or []
return {
"booking_ref": booking_ref,
"shipment_status": status,
"tracking_number": tracking_number,
"events": [dict(row) for row in current_events],
}
async def cancel_booking(self, booking_ref: str, reason: Optional[str], user_id: Optional[int]) -> Dict[str, Any]:
self._assert_enabled()
if self.read_only:
raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)")
shipment = self.get_booking(booking_ref)
if shipment["shipment_status"] == "cancelled":
return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True}
if not self.dry_run and shipment.get("tracking_number"):
try:
await self.client.cancel_shipment(str(shipment["tracking_number"]))
except Exception as exc:
raise HTTPException(status_code=502, detail="Failed to cancel shipment at FedEx") from exc
execute_query(
"""
UPDATE fedex_shipments
SET shipment_status = 'cancelled',
cancel_reason = %s,
updated_by_user_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE booking_ref = %s
""",
(reason, user_id, booking_ref),
)
execute_query(
"""
INSERT INTO fedex_tracking_events (shipment_id, status, description, event_timestamp)
VALUES (%s, 'cancelled', %s, CURRENT_TIMESTAMP)
""",
(shipment["id"], reason or "Cancelled from BMC Hub"),
)
return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True}
fedex_service = FedExService()

View File

@ -1,351 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}FedEx Overblik - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.fedex-shell {
display: grid;
gap: 1rem;
}
.fedex-hero {
border-radius: 14px;
border: 1px solid rgba(15, 76, 117, 0.16);
background: linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(26, 117, 159, 0.08));
padding: 1rem 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.fedex-kpis {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.fedex-kpi {
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 12px;
background: var(--bg-card);
padding: 0.75rem 0.85rem;
}
.fedex-kpi .label {
color: var(--text-secondary);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fedex-kpi .value {
font-size: 1.35rem;
font-weight: 800;
margin-top: 0.2rem;
}
.fedex-filter-card {
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 12px;
}
.fedex-table-wrap {
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 12px;
overflow: auto;
max-height: 70vh;
}
.fedex-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card);
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
.fedex-table tbody td {
vertical-align: middle;
font-size: 0.88rem;
}
.fedex-status {
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.72rem;
font-weight: 700;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
}
.fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; }
.fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; }
.fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; }
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; }
.fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; }
.fedex-row-title {
font-weight: 700;
color: var(--text-primary);
}
.fedex-row-meta {
color: var(--text-secondary);
font-size: 0.78rem;
margin-top: 0.15rem;
}
@media (max-width: 1100px) {
.fedex-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.fedex-kpis {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="fedex-shell">
<div class="fedex-hero">
<div>
<h2 class="fw-bold mb-1">FedEx Overblik</h2>
<div class="text-muted">Samlet visning af alle FedEx bestillinger og deres status.</div>
</div>
<button class="btn btn-outline-primary" id="refreshFedexBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
<div class="fedex-kpis">
<div class="fedex-kpi"><div class="label">Total</div><div class="value" id="kpiTotal">0</div></div>
<div class="fedex-kpi"><div class="label">Aktive</div><div class="value" id="kpiActive">0</div></div>
<div class="fedex-kpi"><div class="label">Leveret</div><div class="value" id="kpiDelivered">0</div></div>
<div class="fedex-kpi"><div class="label">Fejl/Annulleret</div><div class="value" id="kpiFailed">0</div></div>
</div>
<div class="card fedex-filter-card">
<div class="card-body">
<div class="row g-3">
<div class="col-lg-5">
<label class="form-label small text-muted" for="fedexSearchInput">Søg</label>
<input id="fedexSearchInput" class="form-control" placeholder="Booking ref, tracking, modtager, by, case id...">
</div>
<div class="col-lg-3 col-md-6">
<label class="form-label small text-muted" for="fedexStatusFilter">Status</label>
<select id="fedexStatusFilter" class="form-select">
<option value="all">Alle</option>
<option value="draft">Draft</option>
<option value="submitted">Submitted</option>
<option value="booked">Booked</option>
<option value="in_transit">In transit</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="col-lg-2 col-md-6">
<label class="form-label small text-muted" for="fedexSortSelect">Sortering</label>
<select id="fedexSortSelect" class="form-select">
<option value="newest">Nyeste først</option>
<option value="oldest">Ældste først</option>
<option value="status">Status</option>
</select>
</div>
<div class="col-lg-2 d-flex align-items-end">
<button class="btn btn-light border w-100" id="fedexClearBtn">Ryd filtre</button>
</div>
</div>
</div>
</div>
<div class="fedex-table-wrap">
<table class="table table-hover fedex-table mb-0">
<thead>
<tr>
<th>Bestilling</th>
<th>Status</th>
<th>Tracking</th>
<th>Case</th>
<th>Afhentning</th>
<th>Pris</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="fedexTableBody">
<tr><td colspan="7" class="text-center py-4 text-muted">Henter FedEx bestillinger...</td></tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const state = {
bookings: [],
filtered: [],
};
function escapeHtml(value) {
const span = document.createElement('span');
span.textContent = value ?? '';
return span.innerHTML;
}
function formatDate(value) {
if (!value) return '-';
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return '-';
return dt.toLocaleString('da-DK');
}
function formatMoney(amount, currency) {
if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '-';
return `${Number(amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency || 'DKK'}`;
}
function statusBadge(status) {
const s = String(status || 'draft').toLowerCase();
return `<span class="fedex-status ${escapeHtml(s)}">${escapeHtml(s.replaceAll('_', ' '))}</span>`;
}
function applyFilters() {
const q = (document.getElementById('fedexSearchInput')?.value || '').trim().toLowerCase();
const status = (document.getElementById('fedexStatusFilter')?.value || 'all').toLowerCase();
const sortBy = (document.getElementById('fedexSortSelect')?.value || 'newest').toLowerCase();
const rows = state.bookings.filter((item) => {
const itemStatus = String(item.shipment_status || '').toLowerCase();
if (status !== 'all' && itemStatus !== status) return false;
if (!q) return true;
const haystack = [
item.booking_ref,
item.tracking_number,
item.recipient_name,
item.city,
item.country_code,
item.service_type,
item.case_id,
].map((v) => String(v || '').toLowerCase()).join(' ');
return haystack.includes(q);
});
rows.sort((a, b) => {
if (sortBy === 'status') {
return String(a.shipment_status || '').localeCompare(String(b.shipment_status || ''), 'da');
}
const ta = new Date(a.created_at || 0).getTime() || 0;
const tb = new Date(b.created_at || 0).getTime() || 0;
return sortBy === 'oldest' ? ta - tb : tb - ta;
});
state.filtered = rows;
renderTable();
renderKpis();
}
function renderKpis() {
const total = state.filtered.length;
const delivered = state.filtered.filter((item) => item.shipment_status === 'delivered').length;
const failed = state.filtered.filter((item) => ['failed', 'cancelled'].includes(String(item.shipment_status || '').toLowerCase())).length;
const active = state.filtered.filter((item) => ['draft', 'submitted', 'booked', 'in_transit'].includes(String(item.shipment_status || '').toLowerCase())).length;
document.getElementById('kpiTotal').textContent = String(total);
document.getElementById('kpiActive').textContent = String(active);
document.getElementById('kpiDelivered').textContent = String(delivered);
document.getElementById('kpiFailed').textContent = String(failed);
}
function renderTable() {
const tbody = document.getElementById('fedexTableBody');
if (!tbody) return;
if (!state.filtered.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Ingen FedEx bestillinger matcher filteret.</td></tr>';
return;
}
tbody.innerHTML = state.filtered.map((item) => {
const trackingNumber = String(item.tracking_number || '').trim();
const trackingUrl = String(item.tracking_url || (trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim();
const labelUrl = String(item.label_url || '').trim();
const openCaseUrl = Number(item.case_id) > 0 ? `/sag/${Number(item.case_id)}/v3` : '/sag';
return `
<tr>
<td>
<div class="fedex-row-title">${escapeHtml(item.booking_ref || '-')}</div>
<div class="fedex-row-meta">${escapeHtml(item.recipient_name || '-')} • ${escapeHtml(item.city || '-')} (${escapeHtml(item.country_code || '-')})</div>
</td>
<td>${statusBadge(item.shipment_status)}</td>
<td>
${trackingNumber ? `<span class="small fw-semibold">${escapeHtml(trackingNumber)}</span>` : '<span class="text-muted">-</span>'}
</td>
<td><a href="${openCaseUrl}" class="text-decoration-none">#${Number(item.case_id || 0)}</a></td>
<td>${escapeHtml(formatDate(item.pickup_window_start))}</td>
<td>${escapeHtml(formatMoney(item.total_amount, item.currency))}</td>
<td class="text-end">
${trackingUrl ? `<a href="${trackingUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
${labelUrl ? `<a href="${labelUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary ms-1"><i class="bi bi-file-earmark-text"></i></a>` : ''}
</td>
</tr>
`;
}).join('');
}
async function loadBookings() {
const tbody = document.getElementById('fedexTableBody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter FedEx bestillinger...</td></tr>';
}
try {
const response = await fetch('/api/v1/fedex/bookings', { credentials: 'include' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
state.bookings = Array.isArray(payload?.items) ? payload.items : [];
applyFilters();
} catch (error) {
if (tbody) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4 text-danger">Kunne ikke hente FedEx bestillinger: ${escapeHtml(error.message || 'ukendt fejl')}</td></tr>`;
}
}
}
function bindEvents() {
document.getElementById('fedexSearchInput')?.addEventListener('input', applyFilters);
document.getElementById('fedexStatusFilter')?.addEventListener('change', applyFilters);
document.getElementById('fedexSortSelect')?.addEventListener('change', applyFilters);
document.getElementById('fedexClearBtn')?.addEventListener('click', () => {
document.getElementById('fedexSearchInput').value = '';
document.getElementById('fedexStatusFilter').value = 'all';
document.getElementById('fedexSortSelect').value = 'newest';
applyFilters();
});
document.getElementById('refreshFedexBtn')?.addEventListener('click', loadBookings);
}
document.addEventListener('DOMContentLoaded', () => {
bindEvents();
loadBookings();
});
})();
</script>
{% endblock %}

View File

@ -1,14 +0,0 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/support/fedex", response_class=HTMLResponse)
async def fedex_overview_page(request: Request):
return templates.TemplateResponse(
"modules/fedex/frontend/fedex_overview.html",
{"request": request},
)

View File

@ -1,111 +0,0 @@
from datetime import datetime
from typing import List, Optional, Literal
from pydantic import BaseModel, Field
ShipmentStatus = Literal[
"draft",
"submitted",
"booked",
"in_transit",
"delivered",
"cancelled",
"failed",
]
class FedExAddress(BaseModel):
recipient_name: str = Field(min_length=2, max_length=150)
company_name: Optional[str] = Field(default=None, max_length=150)
address_line1: str = Field(min_length=2, max_length=200)
address_line2: Optional[str] = Field(default=None, max_length=200)
postal_code: str = Field(min_length=2, max_length=20)
city: str = Field(min_length=2, max_length=120)
country_code: str = Field(min_length=2, max_length=2)
phone: Optional[str] = Field(default=None, max_length=50)
email: Optional[str] = Field(default=None, max_length=150)
class FedExPackageInput(BaseModel):
weight_kg: float = Field(gt=0, le=2000)
length_cm: float = Field(gt=0, le=400)
width_cm: float = Field(gt=0, le=400)
height_cm: float = Field(gt=0, le=400)
description: str = Field(min_length=1, max_length=255)
class FedExBookingCreate(BaseModel):
case_id: int = Field(gt=0)
customer_id: Optional[int] = Field(default=None, gt=0)
contact_id: Optional[int] = Field(default=None, gt=0)
service_type: Literal["PRIORITY", "ECONOMY"] = "PRIORITY"
pickup_window_start: datetime
pickup_window_end: datetime
address: FedExAddress
packages: List[FedExPackageInput] = Field(min_length=1, max_length=30)
class FedExBookingSubmitResponse(BaseModel):
booking_ref: str
status: ShipmentStatus
dry_run: bool
tracking_number: Optional[str] = None
tracking_url: Optional[str] = None
label_url: Optional[str] = None
total_amount: Optional[float] = None
currency: Optional[str] = None
class FedExTrackingEvent(BaseModel):
status: str
event_timestamp: datetime
description: Optional[str] = None
location_city: Optional[str] = None
location_country: Optional[str] = None
class FedExBookingResponse(BaseModel):
id: int
booking_ref: str
case_id: int
customer_id: Optional[int] = None
contact_id: Optional[int] = None
service_type: str
shipment_status: ShipmentStatus
recipient_name: str
city: str
country_code: str
tracking_number: Optional[str] = None
tracking_url: Optional[str] = None
label_url: Optional[str] = None
total_amount: Optional[float] = None
currency: Optional[str] = None
dry_run: bool
pickup_window_start: datetime
pickup_window_end: datetime
submitted_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
packages: List[FedExPackageInput] = Field(default_factory=list)
class FedExBookingListResponse(BaseModel):
items: List[FedExBookingResponse]
class FedExTrackingResponse(BaseModel):
booking_ref: str
shipment_status: ShipmentStatus
tracking_number: Optional[str] = None
events: List[FedExTrackingEvent] = Field(default_factory=list)
class FedExCancelRequest(BaseModel):
reason: Optional[str] = Field(default=None, max_length=400)
class FedExCancelResponse(BaseModel):
booking_ref: str
status: ShipmentStatus
cancelled: bool

View File

@ -55,90 +55,6 @@ def _eset_extract_company(payload: dict) -> Optional[str]:
return None
def _eset_extract_login_candidates(payload: dict) -> List[str]:
raw = _eset_extract_first_str(
payload,
["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"]
)
if not raw:
return []
candidates: List[str] = []
def _add(value: str) -> None:
v = (value or "").strip().lower()
if v and v not in candidates:
candidates.append(v)
_add(raw)
if "\\" in raw:
_add(raw.split("\\")[-1])
if "/" in raw:
_add(raw.split("/")[-1])
if "@" in raw:
_add(raw.split("@", 1)[0])
return candidates
def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]:
if not login_candidate:
return None
if company:
scoped = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
""",
(login_candidate, company),
)
if scoped:
return scoped[0]["id"]
scoped_local_part = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
""",
(login_candidate, company),
)
if scoped_local_part:
return scoped_local_part[0]["id"]
by_email = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
LIMIT 1
""",
(login_candidate,),
)
if by_email:
return by_email[0]["id"]
by_local_part = execute_query(
"""
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
LIMIT 1
""",
(login_candidate,),
)
if by_local_part:
return by_local_part[0]["id"]
return None
def _eset_detect_asset_type(payload: dict) -> str:
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
if device_type:
@ -173,23 +89,6 @@ def _get_contact_customer(contact_id: int) -> Optional[int]:
return None
def _match_contact_by_name_and_company(full_name: str, company: str) -> Optional[int]:
if not full_name or not company:
return None
query = """
SELECT id
FROM contacts
WHERE LOWER(TRIM(first_name || ' ' || last_name)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
result = execute_query(query, (full_name, company))
if result:
return result[0]["id"]
return None
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
@ -273,22 +172,22 @@ async def list_hardware_by_contact(contact_id: int):
"""
result_new = execute_query(query_new, (contact_id,))
# Also look up hardware_assets by the contact's company (customer link)
query_by_customer = """
# Also check legacy hardware table via customer_id (if contact has companies)
query_legacy = """
SELECT DISTINCT
h.id,
h.asset_type,
h.brand,
NULL as asset_type,
NULL as brand,
h.model,
h.serial_number,
h.anydesk_id,
h.anydesk_link,
h.status,
h.notes,
NULL as anydesk_id,
NULL as anydesk_link,
'active' as status,
NULL as notes,
h.created_at,
'hardware_assets' as source_table
FROM hardware_assets h
WHERE h.current_owner_customer_id IN (
'hardware' as source_table
FROM hardware h
WHERE h.customer_id IN (
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
@ -296,15 +195,10 @@ async def list_hardware_by_contact(contact_id: int):
AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_customer = execute_query(query_by_customer, (contact_id,))
result_legacy = execute_query(query_legacy, (contact_id,))
# Merge: hardware_contacts first (direct link), then customer-linked, dedup by id
seen = set()
all_results = []
for item in (result_new or []) + (result_customer or []):
if item["id"] not in seen:
seen.add(item["id"])
all_results.append(item)
# Merge results, prioritizing new table
all_results = (result_new or []) + (result_legacy or [])
return all_results
@ -374,11 +268,9 @@ async def create_hardware(data: dict):
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group,
rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price
eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
@ -404,11 +296,7 @@ async def create_hardware(data: dict):
data.get("anydesk_link"),
data.get("eset_uuid"),
specs,
data.get("eset_group"),
data.get("rental_default_start_price"),
data.get("rental_default_freight_price"),
data.get("rental_default_preparation_price"),
data.get("rental_default_operations_monthly_price"),
data.get("eset_group")
)
result = execute_query(query, params)
if not result:
@ -509,9 +397,7 @@ async def update_hardware(hardware_id: int, data: dict):
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group",
"rental_default_start_price", "rental_default_freight_price",
"rental_default_preparation_price", "rental_default_operations_monthly_price"
"eset_uuid", "hardware_specs", "eset_group"
]
for field in allowed_fields:
@ -942,60 +828,6 @@ async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
return details
@router.get("/hardware/eset/test-one-pc-full", response_model=dict)
async def test_eset_one_pc_full(include_raw: bool = Query(False)):
"""Fetch one device from ESET and return full parsed test payload including software list."""
payload = await eset_service.list_devices(page_size=1)
if not payload:
raise HTTPException(status_code=404, detail="No devices returned from ESET")
devices = payload.get("devices") or payload.get("items") or payload.get("results") or payload.get("data") or []
if not devices:
raise HTTPException(status_code=404, detail="No devices found in ESET list")
first_device = devices[0]
device_uuid = (
first_device.get("deviceUuid")
or first_device.get("uuid")
or first_device.get("id")
or ""
)
if not device_uuid:
raise HTTPException(status_code=404, detail="No device UUID found on first ESET device")
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device details not found in ESET")
software = eset_service.extract_installed_software(details)
identifier_fields = [
"userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser", "owner", "ownerUuid"
]
identifier_candidates = []
for field_name in identifier_fields:
value = _eset_extract_first_str(details, [field_name])
if value and value not in identifier_candidates:
identifier_candidates.append(value)
user_identifier = identifier_candidates[0] if identifier_candidates else None
response = {
"device_uuid": device_uuid,
"device_name": _eset_extract_first_str(details, ["displayName", "deviceName", "name"]),
"user_identifier": user_identifier,
"group": _eset_extract_group_path(details),
"serial": _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"]),
"identifier_candidates": identifier_candidates,
"installed_software_count": len(software),
"installed_software": software,
}
if include_raw:
response["raw"] = details
return response
@router.get("/hardware/eset/devices", response_model=dict)
async def list_eset_devices(
page_size: Optional[int] = Query(None, ge=1, le=1000),
@ -1027,22 +859,12 @@ async def import_eset_device(data: dict):
group_path = _eset_extract_group_path(details)
group_name = _eset_extract_group_name(details)
company = _eset_extract_company(details)
login_candidates = _eset_extract_login_candidates(details)
full_name = _eset_extract_first_str(details, ["realName", "displayName", "userName", "owner", "user", "lastLoggedInUser"])
if contact_id:
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact_check:
raise HTTPException(status_code=404, detail="Contact not found")
if not contact_id:
contact_id = _match_contact_by_name_and_company(full_name, company)
if not contact_id:
for login_candidate in login_candidates:
contact_id = _match_contact_by_login(login_candidate, company)
if contact_id:
break
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company)

View File

@ -221,130 +221,52 @@ async def hardware_list(
request: Request,
status: str = Query(None),
asset_type: str = Query(None),
rental_scope: str = Query(None),
customer_id: int = Query(None),
q: str = Query(None)
):
"""Display list of BMC-owned assets only."""
query = """
SELECT
ha.*,
c.name AS customer_name,
CASE
WHEN EXISTS (
SELECT 1
FROM subscription_asset_bindings b
WHERE b.asset_id = ha.id
AND b.deleted_at IS NULL
AND b.status = 'active'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
) THEN true
ELSE false
END AS is_currently_rented
FROM hardware_assets ha
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
WHERE ha.deleted_at IS NULL
AND ha.current_owner_type = 'bmc'
"""
"""Display list of all hardware."""
query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL"
params = []
if status:
query += " AND ha.status = %s"
query += " AND status = %s"
params.append(status)
if asset_type:
query += " AND ha.asset_type = %s"
query += " AND asset_type = %s"
params.append(asset_type)
if customer_id:
query += " AND ha.current_owner_customer_id = %s"
query += " AND current_owner_customer_id = %s"
params.append(customer_id)
if q:
query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s)"
query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)"
search_param = f"%{q}%"
params.extend([search_param, search_param, search_param, search_param])
params.extend([search_param, search_param, search_param])
if rental_scope == "rented":
query += """
AND EXISTS (
SELECT 1
FROM subscription_asset_bindings b
WHERE b.asset_id = ha.id
AND b.deleted_at IS NULL
AND b.status = 'active'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
)
"""
elif rental_scope == "not_rented":
query += """
AND NOT EXISTS (
SELECT 1
FROM subscription_asset_bindings b
WHERE b.asset_id = ha.id
AND b.deleted_at IS NULL
AND b.status = 'active'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
)
"""
query += " ORDER BY ha.created_at DESC"
query += " ORDER BY created_at DESC"
hardware = execute_query(query, tuple(params))
# Get customer names for display
if hardware:
customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')]
if customer_ids:
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
# Add customer names to hardware
for h in hardware:
if h.get('current_owner_customer_id'):
h['customer_name'] = customer_map.get(h['current_owner_customer_id'], 'Unknown')
return templates.TemplateResponse("modules/hardware/templates/index.html", {
"request": request,
"hardware": hardware,
"current_status": status,
"current_asset_type": asset_type,
"current_rental_scope": rental_scope,
"search_query": q
})
@router.get("/hardware/customers", response_class=HTMLResponse)
async def customer_hardware_list(
request: Request,
status: str = Query(None),
asset_type: str = Query(None),
customer_id: int = Query(None),
q: str = Query(None)
):
"""Display customer-owned hardware on dedicated page."""
query = """
SELECT
ha.*,
c.name AS customer_name
FROM hardware_assets ha
LEFT JOIN customers c ON c.id = ha.current_owner_customer_id
WHERE ha.deleted_at IS NULL
AND ha.current_owner_type = 'customer'
"""
params = []
if status:
query += " AND ha.status = %s"
params.append(status)
if asset_type:
query += " AND ha.asset_type = %s"
params.append(asset_type)
if customer_id:
query += " AND ha.current_owner_customer_id = %s"
params.append(customer_id)
if q:
query += " AND (ha.serial_number ILIKE %s OR ha.model ILIKE %s OR ha.brand ILIKE %s OR ha.internal_asset_id ILIKE %s OR c.name ILIKE %s)"
search_param = f"%{q}%"
params.extend([search_param, search_param, search_param, search_param, search_param])
query += " ORDER BY c.name ASC NULLS LAST, ha.created_at DESC"
customer_hardware = execute_query(query, tuple(params))
return templates.TemplateResponse("modules/hardware/templates/customers.html", {
"request": request,
"hardware": customer_hardware,
"current_status": status,
"current_asset_type": asset_type,
"search_query": q,
"current_customer_id": customer_id,
})
@router.get("/hardware/new", response_class=HTMLResponse)
async def create_hardware_form(request: Request):
"""Display create hardware form."""
@ -436,7 +358,7 @@ async def hardware_eset_import(request: Request):
})
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details."""
# Get hardware
@ -568,103 +490,6 @@ async def hardware_detail(request: Request, hardware_id: int):
all_locations_flat = execute_query(all_locations_query)
location_tree = build_location_tree(all_locations_flat)
# Rental statistics for this hardware asset
rental_overview_query = """
SELECT
COUNT(*) AS total_bindings,
COALESCE(SUM(CASE WHEN b.status = 'active' AND b.deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS active_bindings,
MIN(b.start_date) AS first_rented_at,
MAX(COALESCE(b.end_date, b.start_date)) AS latest_rental_activity,
COALESCE(SUM(
CASE
WHEN b.status = 'active'
AND b.deleted_at IS NULL
AND s.status IN ('active', 'paused')
THEN COALESCE(i.line_total, 0)
ELSE 0
END
), 0) AS active_mrr,
COALESCE(SUM(
CASE
WHEN b.status = 'active'
AND b.deleted_at IS NULL
AND s.status IN ('draft', 'active', 'paused')
THEN COALESCE(i.line_total, 0)
ELSE 0
END
), 0) AS pipeline_mrr
FROM subscription_asset_bindings b
LEFT JOIN sag_subscriptions s ON s.id = b.subscription_id
LEFT JOIN sag_subscription_items i
ON i.subscription_id = s.id
AND i.asset_id = b.asset_id
WHERE b.asset_id = %s
"""
rental_overview = execute_query(rental_overview_query, (hardware_id,))
rental_overview = (rental_overview or [{}])[0]
rental_revenue_query = """
SELECT
COALESCE(SUM(
CASE
WHEN o.sync_status IN ('exported', 'posted', 'paid')
THEN ((line->>'quantity')::numeric * (line->>'unit_price')::numeric)
ELSE 0
END
), 0) AS exported_or_posted_revenue,
COALESCE(SUM(
CASE
WHEN o.sync_status = 'pending'
THEN ((line->>'quantity')::numeric * (line->>'unit_price')::numeric)
ELSE 0
END
), 0) AS pending_revenue,
COALESCE(SUM((line->>'quantity')::numeric * (line->>'unit_price')::numeric), 0) AS total_revenue,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT CASE WHEN o.sync_status IN ('exported', 'posted', 'paid') THEN o.id END) AS exported_or_posted_order_count,
COUNT(DISTINCT CASE WHEN o.sync_status = 'pending' THEN o.id END) AS pending_order_count
FROM ordre_drafts o
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(o.lines_json, '[]'::jsonb)) line
WHERE line ? 'source_id'
AND (line->>'source_id') ~ '^[0-9]+$'
AND (line->>'source_id')::integer = %s
"""
rental_revenue = execute_query(rental_revenue_query, (hardware_id,))
rental_revenue = (rental_revenue or [{}])[0]
recent_rentals_query = """
SELECT
o.id,
o.title,
o.sync_status,
o.created_at,
COALESCE(SUM((line->>'quantity')::numeric * (line->>'unit_price')::numeric), 0) AS amount
FROM ordre_drafts o
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(o.lines_json, '[]'::jsonb)) line
WHERE line ? 'source_id'
AND (line->>'source_id') ~ '^[0-9]+$'
AND (line->>'source_id')::integer = %s
GROUP BY o.id, o.title, o.sync_status, o.created_at
ORDER BY o.created_at DESC
LIMIT 8
"""
recent_rentals = execute_query(recent_rentals_query, (hardware_id,))
rental_stats = {
"total_bindings": int(rental_overview.get("total_bindings") or 0),
"active_bindings": int(rental_overview.get("active_bindings") or 0),
"first_rented_at": rental_overview.get("first_rented_at"),
"latest_rental_activity": rental_overview.get("latest_rental_activity"),
"active_mrr": float(rental_overview.get("active_mrr") or 0),
"pipeline_mrr": float(rental_overview.get("pipeline_mrr") or 0),
"exported_or_posted_revenue": float(rental_revenue.get("exported_or_posted_revenue") or 0),
"pending_revenue": float(rental_revenue.get("pending_revenue") or 0),
"total_revenue": float(rental_revenue.get("total_revenue") or 0),
"order_count": int(rental_revenue.get("order_count") or 0),
"exported_or_posted_order_count": int(rental_revenue.get("exported_or_posted_order_count") or 0),
"pending_order_count": int(rental_revenue.get("pending_order_count") or 0),
}
return templates.TemplateResponse("modules/hardware/templates/detail.html", {
"request": request,
"hardware": hardware,
@ -678,13 +503,11 @@ async def hardware_detail(request: Request, hardware_id: int):
"owner_customers": owner_customers or [],
"owner_contacts": owner_contacts or [],
"location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware),
"rental_stats": rental_stats,
"recent_rentals": recent_rentals or [],
"eset_specs": extract_eset_specs_summary(hardware)
})
@router.get("/hardware/{hardware_id:int}/edit", response_class=HTMLResponse)
@router.get("/hardware/{hardware_id}/edit", response_class=HTMLResponse)
async def edit_hardware_form(request: Request, hardware_id: int):
"""Display edit hardware form."""
# Get hardware
@ -705,7 +528,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
})
@router.post("/hardware/{hardware_id:int}/location")
@router.post("/hardware/{hardware_id}/location")
async def update_hardware_location(
request: Request,
hardware_id: int,
@ -751,7 +574,7 @@ async def update_hardware_location(
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id:int}/owner")
@router.post("/hardware/{hardware_id}/owner")
async def update_hardware_owner(
request: Request,
hardware_id: int,
@ -826,7 +649,7 @@ async def update_hardware_owner(
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id:int}/contacts/add")
@router.post("/hardware/{hardware_id}/contacts/add")
async def add_hardware_contact(
request: Request,
hardware_id: int,
@ -848,7 +671,7 @@ async def add_hardware_contact(
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id:int}/contacts/{contact_id:int}/delete")
@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete")
async def remove_hardware_contact(
request: Request,
hardware_id: int,

View File

@ -285,30 +285,6 @@ js{% extends "shared/frontend/base.html" %}
</div>
</div>
<!-- Rental Defaults -->
<div class="form-section">
<h3 class="form-section-title">💶 Standardpriser for Udlejning</h3>
<div class="form-grid">
<div class="form-group">
<label for="rental_default_start_price">Standard startpris</label>
<input type="number" id="rental_default_start_price" name="rental_default_start_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_freight_price">Standard fragt</label>
<input type="number" id="rental_default_freight_price" name="rental_default_freight_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_preparation_price">Standard klargoring</label>
<input type="number" id="rental_default_preparation_price" name="rental_default_preparation_price" min="0" step="0.01" value="0">
</div>
<div class="form-group">
<label for="rental_default_operations_monthly_price">Standard drift pr. maned</label>
<input type="number" id="rental_default_operations_monthly_price" name="rental_default_operations_monthly_price" min="0" step="0.01" value="0">
</div>
</div>
<div class="form-text mt-2">Bruges til at autoudfylde Udlej-modal pa asseten.</div>
</div>
<!-- Notes -->
<div class="form-section">
<h3 class="form-section-title">📝 Noter</h3>
@ -380,13 +356,6 @@ js{% extends "shared/frontend/base.html" %}
// Convert customer_id to integer
if (key === 'current_owner_customer_id') {
data[key] = parseInt(value);
} else if (
key === 'rental_default_start_price' ||
key === 'rental_default_freight_price' ||
key === 'rental_default_preparation_price' ||
key === 'rental_default_operations_monthly_price'
) {
data[key] = Number(value);
} else {
data[key] = value;
}

View File

@ -1,104 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Kundehardware - BMC Hub{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
<h1 class="h3 mb-0">Kundehardware</h1>
<div class="d-flex gap-2">
<a href="/hardware" class="btn btn-outline-secondary btn-sm">BMC Assets</a>
<a href="/hardware/new" class="btn btn-primary btn-sm">Nyt Hardware</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/hardware/customers" class="row g-3 align-items-end">
<div class="col-12 col-md-3">
<label for="asset_type" class="form-label">Type</label>
<select name="asset_type" id="asset_type" class="form-select form-select-sm">
<option value="">Alle typer</option>
<option value="pc" {% if current_asset_type == 'pc' %}selected{% endif %}>PC</option>
<option value="laptop" {% if current_asset_type == 'laptop' %}selected{% endif %}>Laptop</option>
<option value="printer" {% if current_asset_type == 'printer' %}selected{% endif %}>Printer</option>
<option value="skærm" {% if current_asset_type == 'skærm' %}selected{% endif %}>Skærm</option>
<option value="telefon" {% if current_asset_type == 'telefon' %}selected{% endif %}>Telefon</option>
<option value="server" {% if current_asset_type == 'server' %}selected{% endif %}>Server</option>
<option value="netværk" {% if current_asset_type == 'netværk' %}selected{% endif %}>Netværk</option>
<option value="andet" {% if current_asset_type == 'andet' %}selected{% endif %}>Andet</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="status" class="form-label">Status</label>
<select name="status" id="status" class="form-select form-select-sm">
<option value="">Alle status</option>
<option value="active" {% if current_status == 'active' %}selected{% endif %}>Aktiv</option>
<option value="faulty_reported" {% if current_status == 'faulty_reported' %}selected{% endif %}>Fejl rapporteret</option>
<option value="in_repair" {% if current_status == 'in_repair' %}selected{% endif %}>Under reparation</option>
<option value="replaced" {% if current_status == 'replaced' %}selected{% endif %}>Udskiftet</option>
<option value="retired" {% if current_status == 'retired' %}selected{% endif %}>Udtjent</option>
<option value="unsupported" {% if current_status == 'unsupported' %}selected{% endif %}>Ikke supporteret</option>
</select>
</div>
<div class="col-12 col-md-4">
<label for="q" class="form-label">Søg</label>
<input type="text" name="q" id="q" class="form-control form-control-sm" value="{{ search_query or '' }}" placeholder="Model, serial, kunde...">
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">Filtrer</button>
</div>
</form>
</div>
</div>
{% if hardware and hardware|length > 0 %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Kunde</th>
<th>Type</th>
<th>Serienr.</th>
<th>Status</th>
<th>AnyDesk</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
{% for item in hardware %}
<tr>
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
<td>{{ item.customer_name or 'Ukendt kunde' }}</td>
<td>{{ item.asset_type|title }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.status|replace('_', ' ')|title }}</td>
<td>
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% else %}
{% endif %}
</td>
<td class="text-end">
<a href="/hardware/{{ item.id }}" class="btn btn-outline-secondary btn-sm">Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn btn-outline-primary btn-sm">Rediger</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<h5>Ingen kundehardware fundet</h5>
<p class="mb-0">Der er ingen kundeejede enheder, der matcher filtre.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -105,56 +105,6 @@
margin-right: 0;
padding-right: 0;
}
.price-tile {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
padding: 1rem;
height: 100%;
}
.price-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.price-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-tile {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
padding: 1rem;
height: 100%;
}
.stat-label {
font-size: 0.82rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.15;
}
.stat-helper {
margin-top: 0.4rem;
font-size: 0.82rem;
color: var(--text-secondary);
}
</style>
{% endblock %}
@ -272,16 +222,6 @@
<i class="bi bi-clock-history me-2"></i>Historik
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="prices-tab" data-bs-toggle="tab" data-bs-target="#prices" type="button" role="tab">
<i class="bi bi-cash-coin me-2"></i>Priser
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button" role="tab">
<i class="bi bi-bar-chart-line me-2"></i>Statistik
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
@ -467,12 +407,6 @@
<span class="small fw-bold">Opret Sag</span>
</a>
</div>
<div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#quickRentModal">
<i class="bi bi-box-seam text-success"></i>
<span class="small fw-bold">Udlej</span>
</div>
</div>
<div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt text-primary"></i>
@ -541,7 +475,7 @@
<div class="list-group list-group-flush">
{% if cases and cases|length > 0 %}
{% for case in cases[:5] %}
<a href="/sag/{{ case.case_id }}/v3" class="list-group-item list-group-item-action border-0 px-3 py-2">
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action border-0 px-3 py-2">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="text-truncate" style="max-width: 70%;">
<i class="bi bi-ticket me-1 text-muted small"></i>
@ -744,175 +678,6 @@
</div>
</div>
<!-- Tab: Prices -->
<div class="tab-pane fade" id="prices" role="tabpanel">
{% set start_price = hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 %}
{% set freight_price = hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 %}
{% set preparation_price = hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 %}
{% set operations_price = hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 %}
{% set startup_total = start_price + freight_price + preparation_price %}
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-primary mb-0"><i class="bi bi-cash-coin me-2"></i>Standardpriser for udlejning</h6>
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary">Rediger priser</a>
</div>
<div class="card-body">
<div class="row g-3 mb-2">
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Startpris</div>
<div class="price-value">{{ "{:,.2f}".format(start_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Fragt</div>
<div class="price-value">{{ "{:,.2f}".format(freight_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Klargoring</div>
<div class="price-value">{{ "{:,.2f}".format(preparation_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Drift pr. maned</div>
<div class="price-value">{{ "{:,.2f}".format(operations_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
</div>
<div class="alert alert-light border mb-0 mt-2">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<span class="text-muted">Standard opstartspris (start + fragt + klargoring)</span>
<span class="fw-bold fs-5">{{ "{:,.2f}".format(startup_total).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Statistics -->
<div class="tab-pane fade" id="statistics" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-bar-chart-line me-2"></i>Udlejningsstatistik for asset</h6>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Total omsaetning</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.total_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Fra alle ordrelinjer pa dette asset</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Eksporteret / bogfort</div>
<div class="stat-value text-success">{{ "{:,.2f}".format(rental_stats.exported_or_posted_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Ordrestatus: exported, posted eller paid</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Afventer fakturering</div>
<div class="stat-value text-warning">{{ "{:,.2f}".format(rental_stats.pending_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Ordrestatus: pending</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Aktiv MRR</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.active_mrr).replace(",", "X").replace(".", ",").replace("X", ".") }} kr./md.</div>
<div class="stat-helper">Aktive/pausede abonnementslinjer</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Udlejninger i alt</div>
<div class="stat-value">{{ rental_stats.total_bindings }}</div>
<div class="stat-helper">Antal bindings pa asset</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Aktive udlejninger</div>
<div class="stat-value">{{ rental_stats.active_bindings }}</div>
<div class="stat-helper">Status active</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Ordrekladder i alt</div>
<div class="stat-value">{{ rental_stats.order_count }}</div>
<div class="stat-helper">Med linjer for dette asset</div>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Pipeline MRR</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.pipeline_mrr).replace(",", "X").replace(".", ",").replace("X", ".") }} kr./md.</div>
<div class="stat-helper">Inkl. draft + active + paused</div>
</div>
</div>
</div>
<div class="card border-0" style="background: rgba(0,0,0,0.02);">
<div class="card-body">
<div class="d-flex flex-wrap gap-4 small text-muted mb-2">
<span><strong>Forste udlejning:</strong> {{ rental_stats.first_rented_at or '-' }}</span>
<span><strong>Seneste aktivitet:</strong> {{ rental_stats.latest_rental_activity or '-' }}</span>
<span><strong>Eksporterede/bogforte ordrer:</strong> {{ rental_stats.exported_or_posted_order_count }}</span>
<span><strong>Afventende ordrer:</strong> {{ rental_stats.pending_order_count }}</span>
</div>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Titel</th>
<th>Status</th>
<th class="text-end">Beloeb</th>
</tr>
</thead>
<tbody>
{% if recent_rentals %}
{% for item in recent_rentals %}
<tr>
<td class="text-muted">{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '-' }}</td>
<td>
<a href="/ordre/{{ item.id }}" class="text-decoration-none">{{ item.title or ('Ordre #' ~ item.id) }}</a>
</td>
<td>
<span class="badge {% if item.sync_status in ['posted', 'paid', 'exported'] %}bg-success{% elif item.sync_status == 'pending' %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
{{ item.sync_status or '-' }}
</span>
</td>
<td class="text-end fw-semibold">{{ "{:,.2f}".format(item.amount or 0).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">Ingen udlejningsordrer fundet for dette asset endnu.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Files -->
<div class="tab-pane fade" id="files" role="tabpanel">
<div class="card shadow-sm border-0">
@ -963,99 +728,6 @@
</div>
</div>
<!-- Quick Rent Modal -->
<div class="modal fade" id="quickRentModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Udlej Hardware #{{ hardware.id }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
</div>
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3">
Vaelg kunde og sag for at se hvad der bliver oprettet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<select class="form-select" id="quickRentCustomerId" required>
<option value="">-- Vaelg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}">{{ customer.navn }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Sag ID</label>
<input type="number" class="form-control" id="quickRentSagId" placeholder="fx 123" min="1" required>
</div>
<div class="col-md-4">
<label class="form-label">Start dato</label>
<input type="date" class="form-control" id="quickRentStartDate" required>
</div>
<div class="col-md-4">
<label class="form-label">Startpris</label>
<input
type="number"
class="form-control"
id="quickRentStartPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Fragt</label>
<input
type="number"
class="form-control"
id="quickRentFreightPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Klargoring</label>
<input
type="number"
class="form-control"
id="quickRentPreparationPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 }}"
>
</div>
<div class="col-md-4">
<label class="form-label">Drift pr. maned</label>
<input
type="number"
class="form-control"
id="quickRentOperationsMonthlyPrice"
min="0"
step="0.01"
value="{{ hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 }}"
required
>
</div>
<div class="col-md-4">
<label class="form-label">Drift i ordre (maneder)</label>
<input type="number" class="form-control" id="quickRentInitialMonths" min="1" max="12" value="2">
</div>
</div>
<div class="form-text mt-2">Standarden er 2 mdr. drift i opstartsordren (1+1).</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" id="quickRentSubmitBtn" onclick="submitQuickRent()">Opret udlejning</button>
</div>
</div>
</div>
</div>
<!-- Modal for Owner -->
<div class="modal fade" id="ownerModal" tabindex="-1">
<div class="modal-dialog">
@ -1251,119 +923,6 @@
}
}
async function submitQuickRent() {
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
const startDate = document.getElementById('quickRentStartDate').value;
const startPrice = Number(document.getElementById('quickRentStartPrice').value || 0);
const freightPrice = Number(document.getElementById('quickRentFreightPrice').value || 0);
const preparationPrice = Number(document.getElementById('quickRentPreparationPrice').value || 0);
const operationsMonthlyPrice = Number(document.getElementById('quickRentOperationsMonthlyPrice').value || 0);
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er paakraevet.');
return;
}
if (operationsMonthlyPrice <= 0) {
alert('Drift pr. maned skal vaere over 0.');
return;
}
const submitBtn = document.getElementById('quickRentSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Opretter...';
try {
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: customerId,
sag_id: sagId,
start_date: startDate,
start_price: startPrice,
freight_price: freightPrice,
preparation_price: preparationPrice,
operations_monthly_price: operationsMonthlyPrice,
initial_operations_months: initialOperationsMonths,
notice_period_days: 30
})
});
const data = await response.json();
if (!response.ok) {
throw new Error((data && data.detail) || 'Quick rent fejlede');
}
const modalEl = document.getElementById('quickRentModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
const draftId = data.ordre_draft_id;
if (draftId) {
window.location.href = `/ordre/${draftId}`;
return;
}
alert('Udlejning oprettet: abonnement + binding oprettet.');
window.location.reload();
} catch (error) {
alert(`Udlejning fejlede: ${error.message}`);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Opret udlejning';
}
}
async function refreshQuickRentPlan() {
const infoEl = document.getElementById('quickRentPlanInfo');
const submitBtn = document.getElementById('quickRentSubmitBtn');
const customerId = Number(document.getElementById('quickRentCustomerId')?.value || 0);
const sagId = Number(document.getElementById('quickRentSagId')?.value || 0);
if (!infoEl || !submitBtn) {
return;
}
if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Vaelg kunde og sag for at se hvad der bliver oprettet.';
submitBtn.disabled = false;
return;
}
try {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Tjekker abonnement paa sagen...';
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json();
if (!response.ok) {
throw new Error((data && data.detail) || 'Preview fejlede');
}
submitBtn.disabled = !data.can_submit;
if (data.action === 'reuse') {
infoEl.className = 'alert alert-success py-2 small mb-3';
} else if (data.action === 'create') {
infoEl.className = 'alert alert-primary py-2 small mb-3';
} else {
infoEl.className = 'alert alert-danger py-2 small mb-3';
}
infoEl.textContent = data.message || 'Klar.';
} catch (error) {
submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3';
infoEl.textContent = `Preview fejl: ${error.message}. Du kan stadig prove at oprette.`;
}
}
// Tree Toggle Function
function toggleLocationChildren(event, nodeId) {
event.preventDefault();
@ -1481,24 +1040,6 @@
// Initialize Tags
document.addEventListener('DOMContentLoaded', function() {
const quickRentStartDate = document.getElementById('quickRentStartDate');
const quickRentCustomerId = document.getElementById('quickRentCustomerId');
const quickRentSagId = document.getElementById('quickRentSagId');
const quickRentModal = document.getElementById('quickRentModal');
if (quickRentStartDate && !quickRentStartDate.value) {
quickRentStartDate.value = new Date().toISOString().slice(0, 10);
}
if (quickRentCustomerId) {
quickRentCustomerId.addEventListener('change', refreshQuickRentPlan);
}
if (quickRentSagId) {
quickRentSagId.addEventListener('input', refreshQuickRentPlan);
}
if (quickRentModal) {
quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan);
}
refreshQuickRentPlan();
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSearch = document.getElementById('ownerContactSearch');

View File

@ -285,58 +285,6 @@
</div>
</div>
<!-- Rental Defaults -->
<div class="form-section">
<h3 class="form-section-title">💶 Standardpriser for Udlejning</h3>
<div class="form-grid">
<div class="form-group">
<label for="rental_default_start_price">Standard startpris</label>
<input
type="number"
id="rental_default_start_price"
name="rental_default_start_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_start_price if hardware.rental_default_start_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_freight_price">Standard fragt</label>
<input
type="number"
id="rental_default_freight_price"
name="rental_default_freight_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_freight_price if hardware.rental_default_freight_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_preparation_price">Standard klargoring</label>
<input
type="number"
id="rental_default_preparation_price"
name="rental_default_preparation_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_preparation_price if hardware.rental_default_preparation_price is not none else 0 }}"
>
</div>
<div class="form-group">
<label for="rental_default_operations_monthly_price">Standard drift pr. maned</label>
<input
type="number"
id="rental_default_operations_monthly_price"
name="rental_default_operations_monthly_price"
min="0"
step="0.01"
value="{{ hardware.rental_default_operations_monthly_price if hardware.rental_default_operations_monthly_price is not none else 0 }}"
>
</div>
</div>
<div class="form-text mt-2">Bruges til at autoudfylde Udlej-modal pa asseten.</div>
</div>
<!-- Notes -->
<div class="form-section">
<h3 class="form-section-title">📝 Noter</h3>
@ -381,13 +329,6 @@
// Convert customer_id to integer
if (key === 'current_owner_customer_id') {
data[key] = parseInt(value);
} else if (
key === 'rental_default_start_price' ||
key === 'rental_default_freight_price' ||
key === 'rental_default_preparation_price' ||
key === 'rental_default_operations_monthly_price'
) {
data[key] = Number(value);
} else {
data[key] = value;
}

View File

@ -169,18 +169,15 @@
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="runOnePcFullTest()">Test 1 PC (ALT)</button>
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
</div>
</div>
<div id="onePcTestStatus" class="contact-muted mb-3"></div>
<div class="table-responsive devices-table">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Navn</th>
<th>Bruger/ID</th>
<th>Serial</th>
<th>Gruppe</th>
<th>Device UUID</th>
@ -189,7 +186,7 @@
</thead>
<tbody id="devicesTable">
<tr>
<td colspan="6" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
<td colspan="5" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
</tr>
</tbody>
</table>
@ -282,42 +279,9 @@
return '';
}
function getNestedField(obj, keys) {
if (!obj || typeof obj !== 'object') return '';
const keySet = new Set((keys || []).map(k => String(k).toLowerCase()));
const stack = [obj];
while (stack.length) {
const current = stack.pop();
if (Array.isArray(current)) {
current.forEach(item => {
if (item && typeof item === 'object') stack.push(item);
});
continue;
}
if (!current || typeof current !== 'object') continue;
for (const [k, v] of Object.entries(current)) {
if (keySet.has(String(k).toLowerCase()) && (typeof v === 'string' || typeof v === 'number')) {
const value = String(v).trim();
if (value) return value;
}
if (v && typeof v === 'object') stack.push(v);
}
}
return '';
}
function getUserIdentifier(device) {
return getNestedField(device, [
'userPrincipalName', 'upn', 'email', 'mail', 'loginName', 'login', 'userName', 'lastLoggedInUser', 'owner', 'ownerUuid'
]);
}
function renderDevices(devices) {
if (!devices.length) {
devicesTable.innerHTML = '<tr><td colspan="6" class="text-center text-muted">Ingen devices fundet.</td></tr>';
devicesTable.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen devices fundet.</td></tr>';
if (devicesCards) {
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
}
@ -327,14 +291,12 @@
devicesTable.innerHTML = devices.map(device => {
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
const name = getField(device, ['displayName', 'deviceName', 'name']);
const login = getUserIdentifier(device);
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
return `
<tr>
<td>${name || '-'}</td>
<td>${login || '-'}</td>
<td>${serial || '-'}</td>
<td>${group || '-'}</td>
<td class="device-uuid">${uuid || '-'}</td>
@ -349,18 +311,15 @@
devicesCards.innerHTML = devices.map((device, index) => {
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
const name = getField(device, ['displayName', 'deviceName', 'name']);
const login = getUserIdentifier(device);
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
const safeName = name || '-';
const safeLogin = login || '-';
const safeSerial = serial || '-';
const safeGroup = group || '-';
const safeUuid = uuid || '';
return `
<div class="device-card" data-index="${index}" data-uuid="${safeUuid}">
<div class="device-card-title">${safeName}</div>
<div class="device-card-meta">Bruger/ID: ${safeLogin}</div>
<div class="device-card-meta">Serial: ${safeSerial}</div>
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
@ -522,30 +481,7 @@
renderDevices(allDevices);
} catch (err) {
deviceStatus.textContent = 'Fejl ved hentning';
devicesTable.innerHTML = `<tr><td colspan="6" class="text-center text-danger">${err.message}</td></tr>`;
}
}
async function runOnePcFullTest() {
const statusEl = document.getElementById('onePcTestStatus');
if (statusEl) statusEl.textContent = 'Korer test...';
try {
const response = await fetch('/api/v1/hardware/eset/test-one-pc-full?include_raw=true');
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const identifier = data.user_identifier || '-';
const softwareCount = Number(data.installed_software_count || 0);
const firstSoftware = (data.installed_software || []).slice(0, 5).join(', ');
const summary = `Test OK. UUID: ${data.device_uuid || '-'} | Login: ${identifier} | Software: ${softwareCount}${firstSoftware ? ` | Eksempel: ${firstSoftware}` : ''}`;
if (statusEl) statusEl.textContent = summary;
console.log('ESET one-PC full test', data);
} catch (err) {
if (statusEl) statusEl.textContent = `Test fejlede: ${err.message}`;
devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
}
}

View File

@ -242,12 +242,8 @@
{% block content %}
<div class="page-header">
<h1>🗂️ BMC Assets Oversigt (Kun vores egne)</h1>
<h1>🖥️ Hardware Oversigt</h1>
<div class="d-flex gap-2">
<a href="/hardware/customers" class="btn-new-hardware" style="background-color: #6c757d;">
<i class="bi bi-building"></i>
Kundehardware
</a>
<a href="/hardware/eset" class="btn-new-hardware" style="background-color: #0f4c75;">
<i class="bi bi-shield-check"></i>
ESET Oversigt
@ -289,15 +285,6 @@
</select>
</div>
<div class="filter-group">
<label for="rental_scope">Udlejning</label>
<select name="rental_scope" id="rental_scope">
<option value="" {% if not current_rental_scope %}selected{% endif %}>Alle assets</option>
<option value="rented" {% if current_rental_scope == 'rented' %}selected{% endif %}>Kun udlejede</option>
<option value="not_rented" {% if current_rental_scope == 'not_rented' %}selected{% endif %}>Kun ikke-udlejede</option>
</select>
</div>
<div class="filter-group">
<label for="q">Søg</label>
<input type="text" name="q" id="q" placeholder="Serial, model, mærke..." value="{{ search_query or '' }}">
@ -375,16 +362,6 @@
<span class="hardware-detail-value">{{ item.internal_asset_id }}</span>
</div>
{% endif %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Udlejning:</span>
<span class="hardware-detail-value">
{% if item.is_currently_rented %}
Udlejet
{% else %}
Ledig/Intern
{% endif %}
</span>
</div>
</div>
<div class="hardware-footer">
@ -410,7 +387,6 @@
<th>Type</th>
<th>Serienr.</th>
<th>Ejer</th>
<th>Udlejning</th>
<th>Status</th>
<th>AnyDesk</th>
<th class="text-end">Handling</th>
@ -423,13 +399,6 @@
<td>{{ item.asset_type|title }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td>
<td>
{% if item.is_currently_rented %}
<span class="status-badge" style="background-color:#0f4c75;color:#fff;">Udlejet</span>
{% else %}
<span class="status-badge" style="background-color:#6c757d;color:#fff;">Ledig/Intern</span>
{% endif %}
</td>
<td>
<span class="status-badge status-{{ item.status }}">
{{ item.status|replace('_', ' ')|title }}
@ -457,18 +426,17 @@
{% else %}
<div class="empty-state">
<div class="empty-state-icon">🖥️</div>
<h3>Ingen BMC assets fundet</h3>
<p>Opret dit første interne asset for at komme i gang.</p>
<h3>Ingen hardware fundet</h3>
<p>Opret dit første hardware asset for at komme i gang.</p>
<a href="/hardware/new" class="btn-new-hardware" style="margin-top: 1rem;"> Opret Hardware</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Auto-submit filter form on change
document.querySelectorAll('#asset_type, #status, #rental_scope').forEach(select => {
document.querySelectorAll('#asset_type, #status').forEach(select => {
select.addEventListener('change', () => {
select.form.submit();
});

View File

@ -1,16 +0,0 @@
# Links Module
Removable operational access layer module.
## Enable
- Set `LINKS_MODULE_ENABLED=true` in `.env`
- Run migrations `154_links_endpoints_module.sql` and `155_links_permissions.sql`
## Disable (soft remove)
- Set `LINKS_MODULE_ENABLED=false`
- Restart API
## Remove (hard)
1. Soft-remove first.
2. Export required data from links tables.
3. Drop module tables (`links`, `link_categories`, `link_category_map`, `link_runbooks`, `link_runbook_steps`, `link_status_checks`, `link_access_log`, `links_audit_log`).

View File

@ -1,8 +0,0 @@
"""
Links Module - Operational access layer
"""
MODULE_NAME = "links"
MODULE_DISPLAY_NAME = "Links / Endpoints"
MODULE_ICON = "bi-link-45deg"
MODULE_DESCRIPTION = "Context-aware operational links and endpoint actions"

View File

@ -1,354 +0,0 @@
import json
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from app.core.auth_dependencies import get_current_user, require_permission
from app.core.database import execute_query
from app.modules.links.backend.service import (
build_action_result,
get_link_category_ids,
get_relevant_links,
log_access,
update_link_categories,
)
from app.modules.links.models.schemas import (
Link,
LinkActionLogCreate,
LinkActionResult,
LinkCategory,
LinkCategoryCreate,
LinkCreate,
LinkLatestStatus,
LinkVaultResolveRequest,
LinkVaultResolveResponse,
LinkUpdate,
RelevantLink,
)
from app.services.vaultwarden_service import resolve_vault_credentials
logger = logging.getLogger(__name__)
router = APIRouter()
def _with_categories(link_row: dict) -> dict:
out = dict(link_row)
out["vault_item_ids"] = out.get("vault_item_ids") or []
out["category_ids"] = get_link_category_ids(int(out["id"]))
return out
@router.get("/links/health")
async def links_health():
execute_query("SELECT 1", ())
return {"status": "healthy", "service": "links-module"}
@router.get("/links/categories", response_model=List[LinkCategory])
async def list_categories(current_user: dict = Depends(require_permission("links.read"))):
del current_user
rows = execute_query(
"SELECT * FROM link_categories ORDER BY sort_order ASC, name ASC",
(),
) or []
return rows
@router.post("/links/categories", response_model=LinkCategory)
async def create_category(
payload: LinkCategoryCreate,
current_user: dict = Depends(require_permission("links.create")),
):
del current_user
rows = execute_query(
"""
INSERT INTO link_categories (name, icon, sort_order)
VALUES (%s, %s, %s)
RETURNING *
""",
(payload.name, payload.icon, payload.sort_order),
)
return rows[0]
@router.get("/links", response_model=List[Link])
async def list_links(
q: Optional[str] = Query(None),
customer_id: Optional[int] = Query(None),
case_id: Optional[int] = Query(None),
hardware_id: Optional[int] = Query(None),
category_id: Optional[int] = Query(None),
is_favorite: Optional[bool] = Query(None),
current_user: dict = Depends(require_permission("links.read")),
):
del current_user
query = """
SELECT l.*
FROM links l
WHERE l.deleted_at IS NULL
"""
params: List[object] = []
if q:
query += " AND (l.name ILIKE %s OR l.url ILIKE %s OR l.host ILIKE %s)"
term = f"%{q}%"
params.extend([term, term, term])
if customer_id is not None:
query += " AND l.customer_id = %s"
params.append(customer_id)
if case_id is not None:
query += " AND l.case_id = %s"
params.append(case_id)
if hardware_id is not None:
query += " AND l.hardware_id = %s"
params.append(hardware_id)
if is_favorite is not None:
query += " AND l.is_favorite = %s"
params.append(is_favorite)
if category_id is not None:
query += " AND EXISTS (SELECT 1 FROM link_category_map lcm WHERE lcm.link_id = l.id AND lcm.category_id = %s)"
params.append(category_id)
query += " ORDER BY l.is_critical DESC, l.updated_at DESC"
rows = execute_query(query, tuple(params) if params else ()) or []
return [_with_categories(row) for row in rows]
@router.get("/links/status/latest", response_model=List[LinkLatestStatus])
async def list_latest_link_status(
link_id: Optional[int] = Query(None),
current_user: dict = Depends(require_permission("links.read")),
):
del current_user
rows = execute_query(
"""
SELECT DISTINCT ON (ls.link_id)
ls.link_id,
ls.status,
ls.checked_at,
ls.details
FROM link_status_checks ls
WHERE (%s IS NULL OR ls.link_id = %s)
ORDER BY ls.link_id, ls.checked_at DESC
""",
(link_id, link_id),
) or []
return rows
@router.get("/links/{link_id}", response_model=Link)
async def get_link(link_id: int, current_user: dict = Depends(require_permission("links.read"))):
del current_user
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,))
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
return _with_categories(rows[0])
@router.post("/links", response_model=Link)
async def create_link(payload: LinkCreate, current_user: dict = Depends(require_permission("links.create"))):
rows = execute_query(
"""
INSERT INTO links (
name, description, type, url, host, port, username, icon, color,
customer_id, case_id, hardware_id,
vault_item_id, vault_item_ids,
is_critical, is_favorite, environment
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
RETURNING *
""",
(
payload.name,
payload.description,
payload.type.value,
payload.url,
payload.host,
payload.port,
payload.username,
payload.icon,
payload.color,
payload.customer_id,
payload.case_id,
payload.hardware_id,
payload.vault_item_id,
json.dumps(payload.vault_item_ids),
payload.is_critical,
payload.is_favorite,
payload.environment.value,
),
)
created = rows[0]
update_link_categories(int(created["id"]), payload.category_ids)
execute_query(
"""
INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes)
VALUES (%s, %s, %s, %s::jsonb)
""",
(created["id"], "created", current_user["id"], json.dumps({"name": payload.name})),
)
return _with_categories(created)
@router.patch("/links/{link_id}", response_model=Link)
async def update_link(
link_id: int,
payload: LinkUpdate,
current_user: dict = Depends(require_permission("links.update")),
):
fields = payload.model_dump(exclude_unset=True)
category_ids = fields.pop("category_ids", None)
updates = []
params: List[object] = []
for field_name, value in fields.items():
if field_name == "type" and value is not None:
updates.append("type = %s")
params.append(value.value)
elif field_name == "environment" and value is not None:
updates.append("environment = %s")
params.append(value.value)
elif field_name == "vault_item_ids" and value is not None:
updates.append("vault_item_ids = %s::jsonb")
params.append(json.dumps(value))
else:
updates.append(f"{field_name} = %s")
params.append(value)
if updates:
updates.append("updated_at = NOW()")
params.append(link_id)
query = f"UPDATE links SET {', '.join(updates)} WHERE id = %s AND deleted_at IS NULL RETURNING *"
rows = execute_query(query, tuple(params)) or []
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
updated = rows[0]
else:
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,))
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
updated = rows[0]
if category_ids is not None:
update_link_categories(link_id, category_ids)
execute_query(
"""
INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes)
VALUES (%s, %s, %s, %s::jsonb)
""",
(link_id, "updated", current_user["id"], json.dumps(fields or {"category_ids": category_ids})),
)
return _with_categories(updated)
@router.delete("/links/{link_id}")
async def delete_link(link_id: int, current_user: dict = Depends(require_permission("links.delete"))):
rows = execute_query(
"UPDATE links SET deleted_at = NOW(), updated_at = NOW() WHERE id = %s AND deleted_at IS NULL RETURNING id",
(link_id,),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
execute_query(
"""
INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes)
VALUES (%s, %s, %s, %s::jsonb)
""",
(link_id, "deleted", current_user["id"], json.dumps({"deleted": True})),
)
return {"status": "deleted", "id": link_id}
@router.get("/links/cases/{case_id}/relevant", response_model=List[RelevantLink])
async def case_relevant_links(
case_id: int,
limit: int = Query(50, ge=1, le=200),
current_user: dict = Depends(require_permission("links.read")),
):
del current_user
return get_relevant_links(case_id, limit=limit)
@router.post("/links/{link_id}/access", response_model=LinkActionResult)
async def access_link(
link_id: int,
payload: LinkActionLogCreate,
current_user: dict = Depends(require_permission("links.use")),
):
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
link_row = rows[0]
action_result = build_action_result(link_row, payload.action_type)
log_access(
link_id=link_id,
user_id=current_user["id"],
action_type=payload.action_type,
case_id=payload.case_id,
customer_id=payload.customer_id,
metadata=payload.metadata,
)
return action_result
@router.post("/links/{link_id}/vault/resolve", response_model=LinkVaultResolveResponse)
async def resolve_link_vault(
link_id: int,
payload: LinkVaultResolveRequest,
current_user: dict = Depends(require_permission("links.use")),
):
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
link_row = rows[0]
fallback_item_ids = link_row.get("vault_item_ids") or []
if not isinstance(fallback_item_ids, list):
fallback_item_ids = []
result = await resolve_vault_credentials(
preferred_item_id=payload.item_id or link_row.get("vault_item_id"),
fallback_item_ids=[str(item) for item in fallback_item_ids if item],
search_hint=payload.search_hint or link_row.get("host") or link_row.get("url") or link_row.get("name"),
)
log_access(
link_id=link_id,
user_id=current_user["id"],
action_type="vault.resolve",
case_id=link_row.get("case_id"),
customer_id=link_row.get("customer_id"),
metadata={
"status": result.get("status"),
"configured": result.get("configured"),
"checked_item_ids": result.get("checked_item_ids") or [],
},
)
return result
@router.post("/links/health/run")
async def run_links_health_check(
current_user: dict = Depends(require_permission("links.diagnose")),
):
del current_user
from app.modules.links.jobs.dead_link_check import check_links_health
result = await check_links_health()
return {"status": "ok", "result": result}

View File

@ -1,229 +0,0 @@
import json
import logging
from typing import Dict, List, Optional
from app.core.database import execute_query, execute_query_single
from app.modules.links.models.schemas import LinkActionResult, LinkScope, LinkType
logger = logging.getLogger(__name__)
def _get_case(case_id: int) -> Optional[dict]:
return execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(case_id,),
)
def _get_case_hardware_ids(case_id: int) -> List[int]:
rows = execute_query(
"SELECT hardware_id FROM sag_hardware WHERE sag_id = %s",
(case_id,),
) or []
return [int(row["hardware_id"]) for row in rows if row.get("hardware_id") is not None]
def _get_tag_ids_for_entity(entity_type: str, entity_id: int) -> List[int]:
rows = execute_query(
"SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s",
(entity_type, entity_id),
) or []
return [int(row["tag_id"]) for row in rows if row.get("tag_id") is not None]
def _get_link_tag_map(link_ids: List[int]) -> Dict[int, List[int]]:
if not link_ids:
return {}
rows = execute_query(
"""
SELECT entity_id AS link_id, tag_id
FROM entity_tags
WHERE entity_type = 'link'
AND entity_id = ANY(%s)
""",
(link_ids,),
) or []
out: Dict[int, List[int]] = {link_id: [] for link_id in link_ids}
for row in rows:
link_id = int(row.get("link_id"))
tag_id = int(row.get("tag_id"))
out.setdefault(link_id, []).append(tag_id)
return out
def _get_link_category_map(link_ids: List[int]) -> Dict[int, List[int]]:
if not link_ids:
return {}
rows = execute_query(
"""
SELECT link_id, category_id
FROM link_category_map
WHERE link_id = ANY(%s)
""",
(link_ids,),
) or []
out: Dict[int, List[int]] = {link_id: [] for link_id in link_ids}
for row in rows:
link_id = int(row.get("link_id"))
category_id = int(row.get("category_id"))
out.setdefault(link_id, []).append(category_id)
return out
def _resolve_scope(link_row: dict, case_id: int, case_customer_id: Optional[int], case_hardware_ids: List[int]) -> tuple[LinkScope, int]:
if link_row.get("case_id") == case_id:
return (LinkScope.case, 1)
if case_customer_id and link_row.get("customer_id") == case_customer_id:
return (LinkScope.customer, 2)
if link_row.get("hardware_id") in case_hardware_ids:
return (LinkScope.hardware, 3)
return (LinkScope.global_scope, 4)
def get_relevant_links(case_id: int, limit: int = 50) -> List[dict]:
case_row = _get_case(case_id)
if not case_row:
return []
case_customer_id = case_row.get("customer_id")
case_hardware_ids = _get_case_hardware_ids(case_id)
case_tag_ids = set(_get_tag_ids_for_entity("case", case_id))
candidate_query = """
SELECT *
FROM links
WHERE deleted_at IS NULL
AND (
case_id = %s
OR (%s IS NOT NULL AND customer_id = %s)
OR (hardware_id IS NOT NULL AND hardware_id = ANY(%s))
OR (case_id IS NULL AND customer_id IS NULL AND hardware_id IS NULL)
)
"""
candidate_rows = execute_query(
candidate_query,
(case_id, case_customer_id, case_customer_id, case_hardware_ids or [0]),
) or []
link_ids = [int(row["id"]) for row in candidate_rows]
link_tag_map = _get_link_tag_map(link_ids)
link_category_map = _get_link_category_map(link_ids)
scored: List[dict] = []
for row in candidate_rows:
link_id = int(row["id"])
link_tags = set(link_tag_map.get(link_id, []))
matched_tags = sorted(case_tag_ids.intersection(link_tags))
scope, scope_priority = _resolve_scope(row, case_id, case_customer_id, case_hardware_ids)
if not matched_tags and scope != LinkScope.case and not row.get("is_critical"):
continue
score = 0
if case_customer_id and row.get("customer_id") == case_customer_id:
score += 3
if row.get("is_critical"):
score += 2
score += len(matched_tags)
row["scope"] = scope.value
row["scope_priority"] = scope_priority
row["score"] = score
row["match_count"] = len(matched_tags)
row["matched_tag_ids"] = matched_tags
row["category_ids"] = link_category_map.get(link_id, [])
scored.append(row)
scored.sort(
key=lambda item: (
item["scope_priority"],
-int(item.get("is_critical") is True),
-item["score"],
item.get("name") or "",
)
)
return scored[:limit]
def update_link_categories(link_id: int, category_ids: List[int]) -> None:
execute_query("DELETE FROM link_category_map WHERE link_id = %s", (link_id,))
if not category_ids:
return
values = []
params: List[int] = []
for category_id in category_ids:
values.append("(%s, %s)")
params.extend([link_id, category_id])
query = f"INSERT INTO link_category_map (link_id, category_id) VALUES {', '.join(values)} ON CONFLICT DO NOTHING"
execute_query(query, tuple(params))
def get_link_category_ids(link_id: int) -> List[int]:
rows = execute_query(
"SELECT category_id FROM link_category_map WHERE link_id = %s ORDER BY category_id",
(link_id,),
) or []
return [int(row["category_id"]) for row in rows]
def log_access(link_id: int, user_id: Optional[int], action_type: str, case_id: Optional[int], customer_id: Optional[int], metadata: Optional[dict]) -> None:
execute_query(
"""
INSERT INTO link_access_log (link_id, user_id, action_type, case_id, customer_id, metadata)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
""",
(link_id, user_id, action_type, case_id, customer_id, json.dumps(metadata or {})),
)
def build_action_result(link_row: dict, action_type: str) -> LinkActionResult:
link_type = LinkType(link_row["type"])
host = link_row.get("host")
port = link_row.get("port")
username = link_row.get("username")
ssh_command = None
rdp_content = None
command_text = None
open_url = link_row.get("url")
if link_type == LinkType.ssh:
if host:
base = "ssh"
if username:
base += f" {username}@{host}"
else:
base += f" {host}"
if port:
base += f" -p {port}"
ssh_command = base
if link_type == LinkType.rdp and host:
rdp_port = port or 3389
rdp_content = f"full address:s:{host}:{rdp_port}\nusername:s:{username or ''}\nprompt for credentials:i:1\n"
if link_type == LinkType.command:
command_text = link_row.get("url") or link_row.get("description") or ""
if link_type in (LinkType.ssh, LinkType.rdp) and not open_url and host:
open_url = host
return LinkActionResult(
link_id=int(link_row["id"]),
action_type=action_type,
type=link_type,
open_url=open_url,
ssh_command=ssh_command,
rdp_content=rdp_content,
command_text=command_text,
username=username,
vault_item_id=link_row.get("vault_item_id"),
vault_search_hint=host or link_row.get("url") or None,
)

View File

@ -1,17 +0,0 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/links", response_class=HTMLResponse)
async def links_index(request: Request):
return templates.TemplateResponse(
"modules/links/templates/index.html",
{"request": request},
)

View File

@ -1,143 +0,0 @@
import asyncio
import json
import logging
import time
from typing import Optional, Tuple
import httpx
from app.core.config import settings
from app.core.database import execute_query
logger = logging.getLogger(__name__)
def _normalize_http_url(url: Optional[str], host: Optional[str]) -> Optional[str]:
candidate = (url or "").strip()
if not candidate and host:
candidate = host.strip()
if not candidate:
return None
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
return f"http://{candidate}"
async def _check_http(client: httpx.AsyncClient, url: str) -> Tuple[str, dict]:
started = time.perf_counter()
try:
response = await client.get(url)
elapsed_ms = int((time.perf_counter() - started) * 1000)
status = "ok" if response.status_code < 400 else "down"
return status, {
"checker": "http",
"url": str(response.url),
"http_status": response.status_code,
"elapsed_ms": elapsed_ms,
}
except Exception as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return "down", {
"checker": "http",
"url": url,
"error": str(exc),
"elapsed_ms": elapsed_ms,
}
async def _check_tcp(host: str, port: int, timeout_seconds: int, checker: str) -> Tuple[str, dict]:
started = time.perf_counter()
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=float(timeout_seconds))
del reader
writer.close()
await writer.wait_closed()
elapsed_ms = int((time.perf_counter() - started) * 1000)
return "ok", {
"checker": checker,
"host": host,
"port": port,
"elapsed_ms": elapsed_ms,
}
except Exception as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return "down", {
"checker": checker,
"host": host,
"port": port,
"error": str(exc),
"elapsed_ms": elapsed_ms,
}
async def _evaluate_link(row: dict, client: httpx.AsyncClient, timeout_seconds: int) -> Tuple[str, dict]:
link_type = row.get("type")
host = row.get("host")
port = row.get("port")
url = row.get("url")
if link_type == "http":
normalized_url = _normalize_http_url(url, host)
if not normalized_url:
return "unknown", {"checker": "http", "reason": "missing_url_or_host"}
return await _check_http(client, normalized_url)
if link_type == "ssh":
if not host:
return "unknown", {"checker": "tcp", "reason": "missing_host", "type": "ssh"}
return await _check_tcp(host, int(port or 22), timeout_seconds, "tcp-ssh")
if link_type == "rdp":
if not host:
return "unknown", {"checker": "tcp", "reason": "missing_host", "type": "rdp"}
return await _check_tcp(host, int(port or 3389), timeout_seconds, "tcp-rdp")
if link_type == "command":
return "unknown", {"checker": "command", "reason": "not_probeable"}
return "unknown", {"checker": "unknown", "reason": f"unsupported_type:{link_type}"}
def _persist_status(link_id: int, status: str, details: dict) -> None:
execute_query(
"""
INSERT INTO link_status_checks (link_id, status, details)
VALUES (%s, %s, %s::jsonb)
""",
(link_id, status, json.dumps(details or {})),
)
async def check_links_health():
rows = execute_query(
"SELECT id, type, url, host, port FROM links WHERE deleted_at IS NULL",
(),
) or []
timeout_seconds = max(1, int(settings.LINKS_CHECK_TIMEOUT_SECONDS))
if settings.LINKS_DRY_RUN:
for row in rows:
_persist_status(int(row["id"]), "unknown", {"reason": "dry_run_enabled"})
logger.info("✅ Links health check skipped by dry-run for %s links", len(rows))
return {"checked": len(rows), "ok": 0, "down": 0, "unknown": len(rows), "dry_run": True}
summary = {"checked": 0, "ok": 0, "down": 0, "unknown": 0, "dry_run": False}
timeout = httpx.Timeout(connect=float(timeout_seconds), read=float(timeout_seconds), write=float(timeout_seconds), pool=float(timeout_seconds))
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
for row in rows:
link_id = int(row["id"])
status, details = await _evaluate_link(row, client, timeout_seconds)
_persist_status(link_id, status, details)
summary["checked"] += 1
summary[status] += 1
logger.info(
"✅ Links health check completed: checked=%s ok=%s down=%s unknown=%s",
summary["checked"],
summary["ok"],
summary["down"],
summary["unknown"],
)
return summary

View File

@ -1,153 +0,0 @@
from datetime import datetime
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field
class LinkType(str, Enum):
http = "http"
ssh = "ssh"
rdp = "rdp"
command = "command"
class LinkEnvironment(str, Enum):
prod = "prod"
test = "test"
dev = "dev"
class LinkScope(str, Enum):
case = "case"
customer = "customer"
hardware = "hardware"
global_scope = "global"
class LinkCategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
icon: Optional[str] = Field(default=None, max_length=100)
sort_order: int = 100
class LinkCategoryCreate(LinkCategoryBase):
pass
class LinkCategory(LinkCategoryBase):
id: int
created_at: datetime
updated_at: datetime
class LinkBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
type: LinkType
url: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = Field(default=None, ge=1, le=65535)
username: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
customer_id: Optional[int] = None
case_id: Optional[int] = None
hardware_id: Optional[int] = None
vault_item_id: Optional[str] = None
vault_item_ids: List[str] = Field(default_factory=list)
is_critical: bool = False
is_favorite: bool = False
environment: LinkEnvironment = LinkEnvironment.prod
class LinkCreate(LinkBase):
category_ids: List[int] = Field(default_factory=list)
class LinkUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
type: Optional[LinkType] = None
url: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = Field(default=None, ge=1, le=65535)
username: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
customer_id: Optional[int] = None
case_id: Optional[int] = None
hardware_id: Optional[int] = None
vault_item_id: Optional[str] = None
vault_item_ids: Optional[List[str]] = None
is_critical: Optional[bool] = None
is_favorite: Optional[bool] = None
environment: Optional[LinkEnvironment] = None
category_ids: Optional[List[int]] = None
class Link(LinkBase):
id: int
category_ids: List[int] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
class RelevantLink(Link):
scope: LinkScope
scope_priority: int
score: int
match_count: int
matched_tag_ids: List[int] = Field(default_factory=list)
category_ids: List[int] = Field(default_factory=list)
class LinkActionLogCreate(BaseModel):
action_type: str = Field(..., min_length=1, max_length=50)
case_id: Optional[int] = None
customer_id: Optional[int] = None
metadata: Optional[dict] = None
class LinkActionResult(BaseModel):
link_id: int
action_type: str
type: LinkType
open_url: Optional[str] = None
ssh_command: Optional[str] = None
rdp_content: Optional[str] = None
command_text: Optional[str] = None
username: Optional[str] = None
vault_item_id: Optional[str] = None
vault_search_hint: Optional[str] = None
class LinkLatestStatus(BaseModel):
link_id: int
status: str
checked_at: datetime
details: dict = Field(default_factory=dict)
class VaultCredential(BaseModel):
item_id: Optional[str] = None
item_name: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
totp: Optional[str] = None
notes: Optional[str] = None
url: Optional[str] = None
class LinkVaultResolveRequest(BaseModel):
item_id: Optional[str] = None
search_hint: Optional[str] = None
class LinkVaultResolveResponse(BaseModel):
status: str
configured: bool
message: Optional[str] = None
checked_item_ids: List[str] = Field(default_factory=list)
credential: Optional[VaultCredential] = None

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,32 +0,0 @@
import time
from typing import Any, Optional
class ManualCache:
"""
Very simple in-memory TTL cache for the Manual MVP.
Stores GET lists and details. Clears fully on any mutation.
"""
def __init__(self, ttl_seconds: int = 300):
self.ttl = ttl_seconds
self._store = {}
def get(self, key: str) -> Optional[Any]:
item = self._store.get(key)
if item is None:
return None
if time.time() > item["expires"]:
del self._store[key]
return None
return item["value"]
def set(self, key: str, value: Any):
self._store[key] = {
"value": value,
"expires": time.time() + self.ttl
}
def clear(self):
self._store.clear()
# Global singleton instance for the app
manual_cache = ManualCache(ttl_seconds=300) # 5 min cache

View File

@ -1,421 +0,0 @@
import logging
import json
import re
from typing import Any, Dict, List, Literal, Optional
from fastapi import BackgroundTasks, APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.modules.manual.backend.cache import manual_cache
from app.core.database import execute_query, execute_query_single, execute_update
logger = logging.getLogger(__name__)
router = APIRouter()
DifficultyType = Literal["beginner", "advanced"]
class ManualStepInput(BaseModel):
step_number: int = Field(ge=1)
title: str = Field(min_length=1, max_length=255)
content: str = Field(min_length=1)
image_url: Optional[str] = None
video_url: Optional[str] = None
class ManualRelationInput(BaseModel):
related_module: Optional[str] = Field(default=None, max_length=80)
related_tag: Optional[str] = Field(default=None, max_length=120)
related_sag_type: Optional[str] = Field(default=None, max_length=120)
related_manual_id: Optional[str] = None
class ManualArticleCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
slug: Optional[str] = Field(default=None, max_length=255)
content: str = Field(min_length=1)
summary: Optional[str] = None
module: str = Field(min_length=1, max_length=80)
tags: List[str] = Field(default_factory=list)
difficulty: DifficultyType = "beginner"
steps: List[ManualStepInput] = Field(default_factory=list)
relations: List[ManualRelationInput] = Field(default_factory=list)
class ManualArticleUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
slug: Optional[str] = Field(default=None, max_length=255)
content: Optional[str] = Field(default=None, min_length=1)
summary: Optional[str] = None
module: Optional[str] = Field(default=None, min_length=1, max_length=80)
tags: Optional[List[str]] = None
difficulty: Optional[DifficultyType] = None
steps: Optional[List[ManualStepInput]] = None
relations: Optional[List[ManualRelationInput]] = None
def _slugify(value: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9\s-]", "", value or "").strip().lower()
cleaned = re.sub(r"[-\s]+", "-", cleaned)
return cleaned[:255] or "manual"
def _normalize_tags(tags: Optional[List[str]]) -> List[str]:
if not tags:
return []
out: List[str] = []
seen = set()
for tag in tags:
clean = str(tag or "").strip().lower()
if not clean:
continue
if clean in seen:
continue
seen.add(clean)
out.append(clean)
return out
def _next_unique_slug(base_slug: str, exclude_id: Optional[str] = None) -> str:
candidate = _slugify(base_slug)
suffix = 1
while True:
if exclude_id:
row = execute_query_single(
"SELECT id FROM manual_articles WHERE slug = %s AND id <> %s AND deleted_at IS NULL",
(candidate, exclude_id),
)
else:
row = execute_query_single(
"SELECT id FROM manual_articles WHERE slug = %s AND deleted_at IS NULL",
(candidate,),
)
if not row:
return candidate
candidate = f"{_slugify(base_slug)}-{suffix}"
suffix += 1
def _fetch_article_by_id(article_id: str) -> Optional[Dict[str, Any]]:
row = execute_query_single(
"""
SELECT id, title, slug, content, summary, module, tags, difficulty,
use_count, created_at, updated_at
FROM manual_articles
WHERE id = %s AND deleted_at IS NULL
""",
(article_id,),
)
if not row:
return None
return _expand_article(row)
def _expand_article(row: Dict[str, Any]) -> Dict[str, Any]:
article = dict(row)
steps = execute_query(
"""
SELECT id, step_number, title, content, image_url, video_url
FROM manual_steps
WHERE manual_id = %s
ORDER BY step_number ASC
""",
(article["id"],),
) or []
relations = execute_query(
"""
SELECT
mr.id,
mr.related_module,
mr.related_tag,
mr.related_sag_type,
mr.related_manual_id,
rm.slug AS related_manual_slug,
rm.title AS related_manual_title
FROM manual_relations mr
LEFT JOIN manual_articles rm
ON rm.id = mr.related_manual_id
AND rm.deleted_at IS NULL
WHERE mr.manual_id = %s
ORDER BY rm.use_count DESC NULLS LAST, rm.updated_at DESC NULLS LAST
""",
(article["id"],),
) or []
article["steps"] = steps
article["relations"] = relations
return article
def _replace_steps(article_id: str, steps: List[ManualStepInput]) -> None:
execute_update("DELETE FROM manual_steps WHERE manual_id = %s", (article_id,))
for step in sorted(steps, key=lambda s: s.step_number):
execute_query(
"""
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
article_id,
step.step_number,
step.title,
step.content,
step.image_url,
step.video_url,
),
)
def _replace_relations(article_id: str, relations: List[ManualRelationInput]) -> None:
execute_update("DELETE FROM manual_relations WHERE manual_id = %s", (article_id,))
for relation in relations:
execute_query(
"""
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
VALUES (%s, %s, %s, %s, %s)
""",
(
article_id,
relation.related_module,
relation.related_tag,
relation.related_sag_type,
relation.related_manual_id,
),
)
@router.get("/manual")
async def list_manual_articles(
module: Optional[str] = Query(default=None),
difficulty: Optional[DifficultyType] = Query(default=None),
tags: Optional[str] = Query(default=None, description="Comma-separated tags"),
search: Optional[str] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
where_parts = ["deleted_at IS NULL"]
params: List[Any] = []
if module:
where_parts.append("LOWER(module) = LOWER(%s)")
params.append(module.strip())
if difficulty:
where_parts.append("difficulty = %s")
params.append(difficulty)
if tags:
tag_list = _normalize_tags([t.strip() for t in tags.split(",") if t.strip()])
if tag_list:
where_parts.append(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE t.tag = ANY(%s::text[]))"
)
params.append(tag_list)
if search:
where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)")
needle = f"%{search.strip()}%"
params.extend([needle, needle, needle])
query = f"""
SELECT id, title, slug, summary, module, tags, difficulty, use_count, created_at, updated_at
FROM manual_articles
WHERE {' AND '.join(where_parts)}
ORDER BY use_count DESC, updated_at DESC, created_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
items = execute_query(query, tuple(params)) or []
return {"items": items, "count": len(items)}
@router.get("/manual/context")
async def contextual_manual_suggestions(
module: Optional[str] = Query(default=None),
tag: Optional[str] = Query(default=None),
sag_type: Optional[str] = Query(default=None),
limit: int = Query(default=10, ge=1, le=50),
):
where_parts = ["ma.deleted_at IS NULL"]
params: List[Any] = []
if module:
where_parts.append("(LOWER(ma.module) = LOWER(%s) OR LOWER(mr.related_module) = LOWER(%s))")
params.extend([module.strip(), module.strip()])
if tag:
where_parts.append(
"(LOWER(mr.related_tag) = LOWER(%s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(ma.tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s)))"
)
params.extend([tag.strip(), tag.strip()])
if sag_type:
where_parts.append("LOWER(mr.related_sag_type) = LOWER(%s)")
params.append(sag_type.strip())
query = f"""
SELECT DISTINCT
ma.id,
ma.title,
ma.slug,
ma.summary,
ma.module,
ma.tags,
ma.difficulty,
ma.use_count,
ma.updated_at
FROM manual_articles ma
LEFT JOIN manual_relations mr ON mr.manual_id = ma.id
WHERE {' AND '.join(where_parts)}
ORDER BY ma.use_count DESC, ma.updated_at DESC
LIMIT %s
"""
params.append(limit)
items = execute_query(query, tuple(params)) or []
return {"items": items, "count": len(items)}
@router.get("/manual/{slug}")
async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
cache_key = f"slug:{slug}"
cached = manual_cache.get(cache_key)
if cached:
background_tasks.add_task(_increment_use_count, cached["id"])
return cached
article = execute_query_single(
"""
SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at
FROM manual_articles
WHERE slug = %s AND deleted_at IS NULL
""",
(slug,),
)
if not article:
raise HTTPException(status_code=404, detail="Manual not found")
execute_update(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(article["id"],),
)
# Read the refreshed count for consistency in response.
article["use_count"] = int(article.get("use_count") or 0) + 1
return _expand_article(article)
@router.post("/manual")
async def create_manual_article(payload: ManualArticleCreate):
try:
wanted_slug = payload.slug or payload.title
slug = _next_unique_slug(wanted_slug)
created = execute_query_single(
"""
INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)
RETURNING id
""",
(
payload.title.strip(),
slug,
payload.content,
payload.summary,
payload.module.strip().lower(),
json.dumps(_normalize_tags(payload.tags), ensure_ascii=False),
payload.difficulty,
),
)
if not created:
raise HTTPException(status_code=500, detail="Could not create manual")
article_id = created["id"]
if payload.steps:
_replace_steps(article_id, payload.steps)
if payload.relations:
_replace_relations(article_id, payload.relations)
article = _fetch_article_by_id(article_id)
if not article:
raise HTTPException(status_code=500, detail="Could not load created manual")
return article
except HTTPException:
raise
except Exception as e:
logger.error("❌ Failed to create manual article: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create manual")
@router.put("/manual/{article_id}")
async def update_manual_article(article_id: str, payload: ManualArticleUpdate):
existing = execute_query_single(
"SELECT id, title, slug FROM manual_articles WHERE id = %s AND deleted_at IS NULL",
(article_id,),
)
if not existing:
raise HTTPException(status_code=404, detail="Manual not found")
updates: List[str] = []
params: List[Any] = []
if payload.title is not None:
updates.append("title = %s")
params.append(payload.title.strip())
if payload.content is not None:
updates.append("content = %s")
params.append(payload.content)
if payload.summary is not None:
updates.append("summary = %s")
params.append(payload.summary)
if payload.module is not None:
updates.append("module = %s")
params.append(payload.module.strip().lower())
if payload.difficulty is not None:
updates.append("difficulty = %s")
params.append(payload.difficulty)
if payload.tags is not None:
updates.append("tags = %s::jsonb")
params.append(json.dumps(_normalize_tags(payload.tags), ensure_ascii=False))
if payload.slug is not None or payload.title is not None:
slug_source = payload.slug or payload.title or existing["slug"]
unique_slug = _next_unique_slug(slug_source, exclude_id=article_id)
updates.append("slug = %s")
params.append(unique_slug)
if updates:
params.append(article_id)
execute_update(
f"UPDATE manual_articles SET {', '.join(updates)} WHERE id = %s",
tuple(params),
)
if payload.steps is not None:
_replace_steps(article_id, payload.steps)
if payload.relations is not None:
_replace_relations(article_id, payload.relations)
article = _fetch_article_by_id(article_id)
if not article:
raise HTTPException(status_code=500, detail="Could not load updated manual")
return article
@router.delete("/manual/{article_id}")
async def delete_manual_article(article_id: str):
affected = execute_update(
"UPDATE manual_articles SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL",
(article_id,),
)
if affected == 0:
raise HTTPException(status_code=404, detail="Manual not found")
return {"success": True, "deleted_id": article_id}

View File

@ -1,177 +0,0 @@
import logging
import html
import re
from typing import Any, List, Optional
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.modules.manual.backend.cache import manual_cache
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
def _normalize_tag_list(value: Any) -> List[str]:
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return []
def _normalize_manual_text(value: Any) -> str:
text = str(value or "")
text = html.unescape(text)
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
text = text.replace("\\n", "\n")
return text
@router.get("/manual", response_class=HTMLResponse)
async def manual_index(
request: Request,
module: Optional[str] = Query(default=None),
difficulty: Optional[str] = Query(default=None),
tag: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
):
cache_key = f"views:list:{module}:{difficulty}:{tag}:{search}"
cached = manual_cache.get(cache_key)
if cached:
rows, modules, unique_tags = cached
else:
filters = ["deleted_at IS NULL"]
params = []
if module:
filters.append("module = %s")
params.append(module)
if difficulty:
filters.append("difficulty = %s")
params.append(difficulty)
if tag:
filters.append("tags @> %s::jsonb")
params.append(f'["{tag}"]')
if search:
filters.append("(title ILIKE %s OR content ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%"])
where_clause = " AND ".join(filters)
rows = execute_query(
f"SELECT id, slug, title, content, module, tags, difficulty, use_count, updated_at "
f"FROM manual_articles WHERE {where_clause} ORDER BY updated_at DESC",
tuple(params)
) or []
modules = execute_query(
"SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC"
) or []
all_tags: List[str] = []
for row in rows:
if "tags" in row and row["tags"]:
try:
import json
if isinstance(row["tags"], str):
t = json.loads(row["tags"])
if isinstance(t, list):
all_tags.extend(t)
elif isinstance(row["tags"], list):
all_tags.extend(row["tags"])
except Exception:
pass
unique_tags = sorted(list(set(all_tags)))
manual_cache.set(cache_key, (rows, modules, unique_tags))
return templates.TemplateResponse(
"modules/manual/templates/list.html",
{
"request": request,
"articles": rows,
"available_modules": modules,
"available_tags": unique_tags,
"filters": {
"module": module or "",
"difficulty": difficulty or "",
"tag": tag or "",
"search": search or "",
},
},
)
@router.get("/manual/admin", response_class=HTMLResponse)
async def manual_admin(request: Request):
articles = execute_query(
"""
SELECT id, title, slug, module, difficulty, use_count, updated_at
FROM manual_articles
WHERE deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT 200
"""
) or []
return templates.TemplateResponse(
"modules/manual/templates/admin.html",
{"request": request, "articles": articles},
)
@router.get("/manual/{slug}", response_class=HTMLResponse)
async def manual_detail(request: Request, slug: str):
article = execute_query_single(
"""
SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at
FROM manual_articles
WHERE slug = %s AND deleted_at IS NULL
""",
(slug,),
)
if not article:
return templates.TemplateResponse(
"modules/manual/templates/detail.html",
{"request": request, "article": None, "steps": [], "related": []},
status_code=404,
)
execute_query(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(article["id"],),
)
article["use_count"] = int(article.get("use_count") or 0) + 1
article["content_normalized"] = _normalize_manual_text(article.get("content"))
article["summary_normalized"] = _normalize_manual_text(article.get("summary"))
steps = execute_query(
"""
SELECT step_number, title, content, image_url, video_url
FROM manual_steps
WHERE manual_id = %s
ORDER BY step_number ASC
""",
(article["id"],),
) or []
for step in steps:
step["content_normalized"] = _normalize_manual_text(step.get("content"))
related = execute_query(
"""
SELECT rm.slug, rm.title, rm.summary, rm.module
FROM manual_relations mr
JOIN manual_articles rm ON rm.id = mr.related_manual_id
WHERE mr.manual_id = %s
AND rm.deleted_at IS NULL
ORDER BY rm.use_count DESC, rm.updated_at DESC
LIMIT 12
""",
(article["id"],),
) or []
return templates.TemplateResponse(
"modules/manual/templates/detail.html",
{"request": request, "article": article, "steps": steps, "related": related},
)

View File

@ -1,248 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Manual Admin - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.admin-shell {
max-width: 1200px;
margin: 0 auto;
}
.editor-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.08);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.85rem;
}
.preview-box {
min-height: 120px;
border-radius: 8px;
border: 1px dashed rgba(0,0,0,0.2);
padding: 0.75rem;
background: var(--bg-body);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 admin-shell">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-sliders me-2"></i>Manual Admin</h2>
<div class="text-muted">Opret og redigér manualartikler (MVP editor)</div>
</div>
<a href="/manual" class="btn btn-outline-primary"><i class="bi bi-journal-richtext me-1"></i>Se manualer</a>
</div>
<div class="row g-3">
<div class="col-12 col-lg-8">
<div class="editor-card p-3">
<div class="row g-2">
<div class="col-12 col-md-8">
<label class="form-label">Titel</label>
<input id="title" class="form-control" placeholder="Hvordan opretter jeg en sag?">
</div>
<div class="col-12 col-md-4">
<label class="form-label">Sværhedsgrad</label>
<select id="difficulty" class="form-select">
<option value="beginner">beginner</option>
<option value="advanced">advanced</option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Modul</label>
<input id="module" class="form-control" placeholder="sag">
</div>
<div class="col-12 col-md-8">
<label class="form-label">Tags (kommasepareret)</label>
<input id="tags" class="form-control" placeholder="sag, ticket, opgave">
</div>
<div class="col-12">
<label class="form-label">Kort intro</label>
<textarea id="summary" class="form-control" rows="2"></textarea>
</div>
<div class="col-12">
<label class="form-label">Markdown indhold</label>
<textarea id="content" class="form-control mono" rows="10" oninput="renderPreview()"></textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Steps JSON</label>
<textarea id="steps" class="form-control mono" rows="8">[]</textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Relationer JSON</label>
<textarea id="relations" class="form-control mono" rows="8">[]</textarea>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button id="saveBtn" class="btn btn-primary" onclick="createManual()">
<i class="bi bi-save me-1"></i>Gem manual
</button>
<button class="btn btn-outline-secondary" onclick="resetForm()">Nulstil</button>
</div>
<div id="status" class="small mt-2 text-muted"></div>
</div>
<div class="editor-card p-3 mt-3">
<h5><i class="bi bi-eye me-2"></i>Preview</h5>
<div id="preview" class="preview-box"></div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="editor-card p-3">
<h5><i class="bi bi-list-check me-2"></i>Seneste manualer</h5>
<div class="list-group list-group-flush">
{% for article in articles %}
<div class="list-group-item">
<div class="fw-semibold">{{ article.title }}</div>
<div class="small text-muted mb-2">{{ article.module }} • {{ article.difficulty }}</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/manual/{{ article.slug }}">Åbn</a>
<button class="btn btn-sm btn-outline-secondary" onclick="loadManual('{{ article.slug }}')">Rediger</button>
</div>
</div>
{% else %}
<div class="text-muted">Ingen manualer endnu.</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let editingId = null;
function normalizeEditorText(value) {
return String(value || '')
.replace(/&lt;br\s*\/?&gt;/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/\\n/g, '\n');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.innerText = text || '';
return div.innerHTML;
}
function renderPreview() {
const value = document.getElementById('content').value || '';
const html = escapeHtml(value).replace(/\n/g, '<br>');
document.getElementById('preview').innerHTML = html;
}
function parseJsonField(id) {
const raw = document.getElementById(id).value || '[]';
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
throw new Error(`Ugyldig JSON i feltet ${id}`);
}
}
async function createManual() {
const status = document.getElementById('status');
const saveBtn = document.getElementById('saveBtn');
status.textContent = 'Gemmer...';
try {
const payload = {
title: document.getElementById('title').value.trim(),
module: document.getElementById('module').value.trim(),
difficulty: document.getElementById('difficulty').value,
summary: document.getElementById('summary').value.trim(),
content: document.getElementById('content').value,
tags: (document.getElementById('tags').value || '').split(',').map(t => t.trim()).filter(Boolean),
steps: parseJsonField('steps'),
relations: parseJsonField('relations')
};
if (!payload.title || !payload.module || !payload.content) {
throw new Error('Titel, modul og indhold er påkrævet.');
}
const endpoint = editingId ? `/api/v1/manual/${editingId}` : '/api/v1/manual';
const method = editingId ? 'PUT' : 'POST';
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Kunne ikke gemme manual.');
}
status.textContent = (editingId ? 'Manual opdateret: ' : 'Manual gemt: ') + (data.slug || data.title);
saveBtn.innerHTML = '<i class="bi bi-save me-1"></i>Gem manual';
window.setTimeout(() => {
window.location.href = '/manual/' + data.slug;
}, 500);
} catch (error) {
status.textContent = 'Fejl: ' + (error.message || error);
}
}
function resetForm() {
editingId = null;
document.getElementById('title').value = '';
document.getElementById('module').value = '';
document.getElementById('summary').value = '';
document.getElementById('content').value = '';
document.getElementById('tags').value = '';
document.getElementById('steps').value = '[]';
document.getElementById('relations').value = '[]';
document.getElementById('status').textContent = '';
document.getElementById('saveBtn').innerHTML = '<i class="bi bi-save me-1"></i>Gem manual';
renderPreview();
}
async function loadManual(slug) {
const status = document.getElementById('status');
status.textContent = 'Henter manual...';
try {
const response = await fetch(`/api/v1/manual/${encodeURIComponent(slug)}`, {
method: 'GET',
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Kunne ikke hente manual.');
}
editingId = data.id;
document.getElementById('title').value = data.title || '';
document.getElementById('module').value = data.module || '';
document.getElementById('summary').value = normalizeEditorText(data.summary);
document.getElementById('content').value = normalizeEditorText(data.content);
document.getElementById('difficulty').value = data.difficulty || 'beginner';
document.getElementById('tags').value = (data.tags || []).join(', ');
const normalizedSteps = (data.steps || []).map((step) => ({
...step,
content: normalizeEditorText(step.content)
}));
document.getElementById('steps').value = JSON.stringify(normalizedSteps, null, 2);
document.getElementById('relations').value = JSON.stringify(data.relations || [], null, 2);
document.getElementById('saveBtn').innerHTML = '<i class="bi bi-pencil-square me-1"></i>Opdater manual';
renderPreview();
status.textContent = 'Redigerer: ' + (data.title || slug);
} catch (error) {
status.textContent = 'Fejl: ' + (error.message || error);
}
}
renderPreview();
</script>
{% endblock %}

View File

@ -1,136 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{% if article %}{{ article.title }} - Manual{% else %}Manual ikke fundet{% endif %} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.manual-shell {
max-width: 1100px;
margin: 0 auto;
}
.manual-section {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
}
.step-card {
border-left: 4px solid var(--accent);
border-radius: 8px;
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
}
.step-media img,
.step-media video {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
margin-top: 0.5rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
font-size: 0.75rem;
margin-right: 0.35rem;
margin-bottom: 0.35rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 manual-shell">
{% if article %}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<a href="/manual" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Tilbage til manualer</a>
<h2 class="mt-2 mb-1">{{ article.title }}</h2>
<div class="text-muted">{{ article.summary or 'Ingen kort beskrivelse.' }}</div>
</div>
<div class="text-end">
<div><span class="badge bg-secondary">{{ article.difficulty }}</span></div>
<div class="small text-muted mt-1"><i class="bi bi-eye me-1"></i>{{ article.use_count or 0 }} visninger</div>
</div>
</div>
<div class="manual-section p-3 mb-3">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<span class="fw-semibold"><i class="bi bi-grid me-1"></i>Modul:</span>
<span class="badge text-bg-light">{{ article.module|title }}</span>
<a href="/manual?module={{ article.module }}" class="btn btn-sm btn-outline-primary ms-2">Åbn i kontekst</a>
</div>
<div>
{% for tag in article.tags or [] %}
<span class="tag-chip">#{{ tag }}</span>
{% endfor %}
</div>
</div>
<div class="manual-section p-3 mb-3">
<h5 class="mb-2"><i class="bi bi-journal-text me-2"></i>Guide</h5>
{% set guide_text = article.content_normalized if article.content_normalized is defined and article.content_normalized else article.content %}
<div class="text-body" style="white-space: pre-line; line-height: 1.6;">{{ guide_text | default('', true) | e | replace('&lt;br&gt;', '\n') | replace('&lt;br/&gt;', '\n') | replace('&lt;br /&gt;', '\n') | replace('\\n', '\n') }}</div>
</div>
<div class="manual-section p-3 mb-3">
<h5 class="mb-3"><i class="bi bi-list-ol me-2"></i>Step-by-step</h5>
{% if steps %}
<div class="d-grid gap-2">
{% for step in steps %}
<div class="step-card p-3">
<div class="fw-semibold mb-1">Step {{ step.step_number }}: {{ step.title }}</div>
{% set step_text = step.content_normalized if step.content_normalized is defined and step.content_normalized else step.content %}
<div class="text-body" style="white-space: pre-line;">{{ step_text | default('', true) | e | replace('&lt;br&gt;', '\n') | replace('&lt;br/&gt;', '\n') | replace('&lt;br /&gt;', '\n') | replace('\\n', '\n') }}</div>
{% if step.image_url or step.video_url %}
<div class="step-media mt-2">
{% if step.image_url %}
<img src="{{ step.image_url }}" alt="Step billede" loading="lazy">
{% endif %}
{% if step.video_url %}
<video controls preload="none">
<source src="{{ step.video_url }}">
</video>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted">Ingen steps endnu.</div>
{% endif %}
</div>
<div class="manual-section p-3">
<h5 class="mb-3"><i class="bi bi-link-45deg me-2"></i>Relaterede guides</h5>
{% if related %}
<div class="row g-2">
{% for item in related %}
<div class="col-12 col-md-6">
<div class="card border-0" style="background: var(--accent-light);">
<div class="card-body">
<div class="fw-semibold">{{ item.title }}</div>
<div class="small text-muted mb-2">{{ item.summary or 'Relateret guide' }}</div>
<a class="btn btn-sm btn-outline-primary" href="/manual/{{ item.slug }}">Åbn</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted">Ingen relaterede guider fundet.</div>
{% endif %}
</div>
{% else %}
<div class="card border-0">
<div class="card-body text-center py-5">
<i class="bi bi-journal-x" style="font-size: 2rem; color: var(--text-secondary);"></i>
<h4 class="mt-3">Manual ikke fundet</h4>
<a href="/manual" class="btn btn-primary mt-2">Tilbage til manualer</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -1,131 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Manualer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.manual-header {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.08);
padding: 1rem;
}
.manual-card {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
height: 100%;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.manual-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
}
.manual-meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
font-size: 0.75rem;
margin-right: 0.35rem;
margin-bottom: 0.35rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-journal-richtext me-2"></i>Manualer</h2>
<div class="text-muted">Kontekstuel og søgbar hjælp til alle moduler</div>
</div>
<div class="d-flex gap-2">
<a href="/manual/admin" class="btn btn-outline-primary">
<i class="bi bi-gear me-1"></i>Admin
</a>
<a href="/manual?module={{ filters.module }}" class="btn btn-primary">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</a>
</div>
</div>
<div class="manual-header mb-3">
<form method="get" class="row g-2">
<div class="col-12 col-lg-4">
<input type="text" class="form-control" name="search" value="{{ filters.search }}" placeholder="Søg i titel, resume og indhold">
</div>
<div class="col-6 col-lg-2">
<select class="form-select" name="module">
<option value="">Alle moduler</option>
{% for m in available_modules %}
<option value="{{ m.module }}" {% if filters.module == m.module %}selected{% endif %}>{{ m.module|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-lg-2">
<select class="form-select" name="difficulty">
<option value="">Alle niveauer</option>
<option value="beginner" {% if filters.difficulty == 'beginner' %}selected{% endif %}>Beginner</option>
<option value="advanced" {% if filters.difficulty == 'advanced' %}selected{% endif %}>Advanced</option>
</select>
</div>
<div class="col-8 col-lg-3">
<select class="form-select" name="tag">
<option value="">Alle tags</option>
{% for t in available_tags %}
<option value="{{ t }}" {% if filters.tag == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-4 col-lg-1 d-grid">
<button class="btn btn-primary" type="submit"><i class="bi bi-search"></i></button>
</div>
</form>
</div>
<div class="row g-3">
{% if articles %}
{% for article in articles %}
<div class="col-12 col-md-6 col-xl-4">
<div class="manual-card p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="mb-0">{{ article.title }}</h5>
<span class="badge bg-secondary">{{ article.difficulty }}</span>
</div>
<div class="manual-meta mb-2">
<i class="bi bi-grid me-1"></i>{{ article.module|title }}
<span class="mx-2"></span>
<i class="bi bi-eye me-1"></i>{{ article.use_count or 0 }}
</div>
<p class="text-muted mb-2">{{ article.summary or 'Ingen introduktion endnu.' }}</p>
<div class="mb-3">
{% for tag in article.tags or [] %}
<span class="tag-chip">#{{ tag }}</span>
{% endfor %}
</div>
<a href="/manual/{{ article.slug }}" class="btn btn-sm btn-outline-primary">
Åbn guide <i class="bi bi-arrow-right-short"></i>
</a>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="card border-0">
<div class="card-body text-center py-5">
<i class="bi bi-search" style="font-size: 2rem; color: var(--text-secondary);"></i>
<div class="mt-2">Ingen manualer matcher filteret.</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More