Compare commits
No commits in common. "main" and "feature/sag-tidsforbrug-v1" have entirely different histories.
main
...
feature/sa
40
.env.example
40
.env.example
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -7,7 +7,6 @@ RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
libpq-dev \
|
||||
libzbar0 \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
|
||||
@ -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
|
||||
```
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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`
|
||||
142
add_css.py
142
add_css.py
@ -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!')
|
||||
@ -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
@ -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"}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
@ -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
|
||||
@ -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",
|
||||
)
|
||||
@ -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
@ -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
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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 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>
|
||||
</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 =
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
@ -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, '<').replace(/>/g, '>');
|
||||
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 %}
|
||||
@ -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"},
|
||||
)
|
||||
@ -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:
|
||||
# Mark as read
|
||||
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
|
||||
execute_update(update_query, (email_id,))
|
||||
email_data["is_read"] = True
|
||||
|
||||
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
@ -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 %}
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
@ -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),
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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))
|
||||
@ -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()
|
||||
@ -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 %}
|
||||
@ -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},
|
||||
)
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 %}
|
||||
@ -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');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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`).
|
||||
@ -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"
|
||||
@ -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}
|
||||
@ -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,
|
||||
)
|
||||
@ -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},
|
||||
)
|
||||
@ -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
|
||||
@ -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
@ -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,53 +1203,23 @@ async def create_contact(location_id: int, data: ContactCreate):
|
||||
"""
|
||||
execute_query(unset_primary_query, (location_id,))
|
||||
|
||||
has_related_contact_id_column = bool(execute_query(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'locations_contacts'
|
||||
AND column_name = 'related_contact_id'
|
||||
LIMIT 1
|
||||
"""
|
||||
))
|
||||
|
||||
# INSERT new contact
|
||||
if has_related_contact_id_column:
|
||||
insert_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, related_contact_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
location_id,
|
||||
data.existing_contact_id,
|
||||
contact_name,
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_role,
|
||||
data.is_primary,
|
||||
)
|
||||
else:
|
||||
insert_query = """
|
||||
INSERT INTO locations_contacts (
|
||||
location_id, contact_name, contact_email, contact_phone,
|
||||
role, is_primary, created_at
|
||||
role, is_primary, created_at, updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
params = (
|
||||
location_id,
|
||||
contact_name,
|
||||
contact_email,
|
||||
contact_phone,
|
||||
contact_role,
|
||||
data.is_primary,
|
||||
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:
|
||||
|
||||
@ -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("""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,18 +56,23 @@
|
||||
'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 %}
|
||||
{% if location.is_active %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/app/locations/{{ location.id }}/edit" class="btn btn-primary btn-sm">
|
||||
@ -269,37 +86,11 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="case-hero-meta">
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Navn</div>
|
||||
<div class="hero-meta-value">{{ location.name }}</div>
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Overordnet</div>
|
||||
{% if location.parent_location_id and location.parent_location_name %}
|
||||
<a href="/app/locations/{{ location.parent_location_id }}" class="hero-meta-value text-decoration-none">
|
||||
{{ location.parent_location_name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="hero-meta-value text-muted">Ingen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Kontakter</div>
|
||||
<div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div>
|
||||
</div>
|
||||
<div class="case-meta-cell">
|
||||
<div class="hero-meta-label">Tjenester</div>
|
||||
<div class="hero-meta-value">{{ location.services|length if location.services else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<ul class="nav nav-tabs mb-4" 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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}
|
||||
@ -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},
|
||||
)
|
||||
@ -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(/<br\s*\/?>/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 %}
|
||||
@ -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('<br>', '\n') | replace('<br/>', '\n') | replace('<br />', '\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('<br>', '\n') | replace('<br/>', '\n') | replace('<br />', '\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 %}
|
||||
@ -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 %}
|
||||
@ -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))]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sourceBadge(type) {
|
||||
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
|
||||
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
|
||||
@ -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
Loading…
Reference in New Issue
Block a user