feat: Add subscription management functionality and AnyDesk API integration

- Implemented subscription creation, updating, and rendering in script_9.js.
- Added functions for handling subscription line items, product selection, and total calculations.
- Integrated AnyDesk API for session management in test_anydesk.py.
- Created REST client test requests for API endpoints in api.http.
- Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py.
This commit is contained in:
Christian 2026-03-30 07:50:15 +02:00
parent 5b24c5d978
commit bc504b9257
81 changed files with 15355 additions and 532 deletions

View File

@ -69,6 +69,19 @@ 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
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL=
VAULTWARDEN_API_TOKEN=
# =====================================================
# vTiger Cloud Integration (Required for Subscriptions)
# =====================================================

View File

@ -76,3 +76,17 @@ 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
# =====================================================
# 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
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL=
VAULTWARDEN_API_TOKEN=

142
add_css.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -70,6 +70,17 @@ 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
# 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 = ""
@ -227,9 +238,10 @@ 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 = ""
ANYDESK_PASSWORD: str = ""
ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com
ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN
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

View File

@ -79,6 +79,35 @@ 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:
@ -104,6 +133,57 @@ 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
@ -213,7 +293,14 @@ 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

View File

@ -55,6 +55,90 @@ 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:
@ -89,6 +173,23 @@ 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)
@ -172,22 +273,22 @@ async def list_hardware_by_contact(contact_id: int):
"""
result_new = execute_query(query_new, (contact_id,))
# Also check legacy hardware table via customer_id (if contact has companies)
query_legacy = """
# Also look up hardware_assets by the contact's company (customer link)
query_by_customer = """
SELECT DISTINCT
h.id,
NULL as asset_type,
NULL as brand,
h.asset_type,
h.brand,
h.model,
h.serial_number,
NULL as anydesk_id,
NULL as anydesk_link,
'active' as status,
NULL as notes,
h.anydesk_id,
h.anydesk_link,
h.status,
h.notes,
h.created_at,
'hardware' as source_table
FROM hardware h
WHERE h.customer_id IN (
'hardware_assets' as source_table
FROM hardware_assets h
WHERE h.current_owner_customer_id IN (
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
@ -195,10 +296,15 @@ async def list_hardware_by_contact(contact_id: int):
AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_legacy = execute_query(query_legacy, (contact_id,))
result_customer = execute_query(query_by_customer, (contact_id,))
# Merge results, prioritizing new table
all_results = (result_new or []) + (result_legacy or [])
# 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)
return all_results
@ -828,6 +934,60 @@ 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),
@ -859,12 +1019,22 @@ async def import_eset_device(data: dict):
group_path = _eset_extract_group_path(details)
group_name = _eset_extract_group_name(details)
company = _eset_extract_company(details)
login_candidates = _eset_extract_login_candidates(details)
full_name = _eset_extract_first_str(details, ["realName", "displayName", "userName", "owner", "user", "lastLoggedInUser"])
if contact_id:
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact_check:
raise HTTPException(status_code=404, detail="Contact not found")
if not contact_id:
contact_id = _match_contact_by_name_and_company(full_name, company)
if not contact_id:
for login_candidate in login_candidates:
contact_id = _match_contact_by_login(login_candidate, company)
if contact_id:
break
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company)

View File

@ -169,15 +169,18 @@
<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>
@ -186,7 +189,7 @@
</thead>
<tbody id="devicesTable">
<tr>
<td colspan="5" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
<td colspan="6" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
</tr>
</tbody>
</table>
@ -279,9 +282,42 @@
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="5" class="text-center text-muted">Ingen devices fundet.</td></tr>';
devicesTable.innerHTML = '<tr><td colspan="6" class="text-center text-muted">Ingen devices fundet.</td></tr>';
if (devicesCards) {
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
}
@ -291,12 +327,14 @@
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>
@ -311,15 +349,18 @@
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>
@ -481,7 +522,30 @@
renderDevices(allDevices);
} catch (err) {
deviceStatus.textContent = 'Fejl ved hentning';
devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
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}`;
}
}

View File

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

View File

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

View File

View File

@ -0,0 +1,279 @@
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,
LinkUpdate,
RelevantLink,
)
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/{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

View File

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

View File

View File

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

View File

View File

@ -0,0 +1,18 @@
import logging
from app.core.database import execute_query
logger = logging.getLogger(__name__)
async def check_links_health():
rows = execute_query("SELECT id, type, url, host FROM links WHERE deleted_at IS NULL", ()) or []
for row in rows:
execute_query(
"""
INSERT INTO link_status_checks (link_id, status, details)
VALUES (%s, %s, %s::jsonb)
""",
(row["id"], "unknown", '{"reason":"initial implementation placeholder"}'),
)
logger.info("✅ Links health placeholder executed for %s links", len(rows))

View File

View File

@ -0,0 +1,123 @@
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

View File

@ -0,0 +1,19 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Links{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">Links / Endpoints</h1>
<p class="text-muted mb-0">Operational access layer module (phase 1 foundation)</p>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="mb-0">Module page scaffold is active. Use API endpoints under <code>/api/v1/links</code>.</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -12,7 +12,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single
from app.core.database import execute_query, execute_query_single, table_has_column
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings
from app.services.email_service import EmailService
@ -2326,18 +2326,26 @@ async def get_sag_emails(sag_id: int):
SELECT
e.*,
COALESCE(
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.thread_key, '')), '[<>\\s]', '', 'g'), ''),
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''),
NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''),
NULLIF(
REGEXP_REPLACE(
LOWER(TRIM(COALESCE(e.subject, ''))),
'^(?:re|fw|fwd)\\s*:\\s*',
(REGEXP_SPLIT_TO_ARRAY(COALESCE(e.in_reply_to, ''), E'[\\s,]+'))[1],
'[<>\\s]',
'',
'g'
),
''
),
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.thread_key, '')), '[<>\\s]', '', 'g'), ''),
NULLIF(
REGEXP_REPLACE(
LOWER(TRIM(COALESCE(e.subject, ''))),
'^(?:(?:re|fw|fwd|sv|aw)\\s*:\\s*)+',
'',
'i'
),
''
),
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''),
CONCAT('email-', e.id::text)
) AS resolved_thread_key
@ -2515,12 +2523,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
in_reply_to_header = None
references_header = None
selected_thread_key = None
if payload.thread_email_id:
thread_row = None
try:
thread_row = execute_query_single(
"""
SELECT id, message_id, in_reply_to, email_references
SELECT id, message_id, in_reply_to, email_references, thread_key
FROM email_messages
WHERE id = %s
""",
@ -2528,14 +2537,24 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
)
except Exception:
# Backward compatibility for DBs without in_reply_to/email_references columns.
thread_row = execute_query_single(
"""
SELECT id, message_id
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
try:
thread_row = execute_query_single(
"""
SELECT id, message_id, thread_key
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
except Exception:
thread_row = execute_query_single(
"""
SELECT id, message_id
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
if thread_row:
base_message_id = str(thread_row.get("message_id") or "").strip()
if base_message_id and not base_message_id.startswith("<"):
@ -2549,8 +2568,17 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
else:
references_header = base_message_id
selected_thread_key = _derive_thread_key_for_outbound(
thread_row.get("thread_key"),
thread_row.get("in_reply_to"),
thread_row.get("email_references"),
thread_row.get("message_id"),
)
effective_payload_thread_key = payload.thread_key or selected_thread_key
provisional_thread_key = _derive_thread_key_for_outbound(
payload.thread_key,
effective_payload_thread_key,
in_reply_to_header,
references_header,
None,
@ -2584,16 +2612,28 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
thread_key = _normalize_message_id_token(provider_thread_key)
provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key)
# Keep replies in the existing case thread when we already know the target thread.
# Some providers may return a new conversation id even for replies.
derived_thread_key = _derive_thread_key_for_outbound(
effective_payload_thread_key,
in_reply_to_header,
references_header,
None,
)
thread_key = derived_thread_key or provider_thread_key_normalized
if not thread_key:
thread_key = _derive_thread_key_for_outbound(
payload.thread_key,
effective_payload_thread_key,
in_reply_to_header,
references_header,
generated_message_id,
)
insert_result = None
insert_error = None
try:
insert_email_query = """
INSERT INTO email_messages (
@ -2648,89 +2688,183 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
sag_id,
),
)
except Exception:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
status = 'sent',
import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method),
linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id),
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
body_html,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"manual_upload",
sag_id,
),
)
except Exception as e:
insert_error = e
logger.warning("⚠️ Outbound email full insert fallback for case %s: %s", sag_id, e)
if not insert_result:
logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id)
raise HTTPException(status_code=500, detail="Email sent but logging failed")
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
status = 'sent',
import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method),
linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id),
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
body_html,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"manual_upload",
sag_id,
),
)
except Exception as e:
insert_error = e
logger.warning("⚠️ Outbound email medium insert fallback for case %s: %s", sag_id, e)
email_id = insert_result[0]["id"]
if not insert_result:
# Legacy-safe fallback: persist with minimal guaranteed columns.
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text,
received_date, folder, has_attachments, attachment_count
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
),
)
except Exception as e:
insert_error = e
logger.error("❌ Email sent but outbound log insert failed for case %s: %s", sag_id, e)
if smtp_attachments:
email_id = None
if insert_result:
email_id = insert_result[0]["id"]
else:
# Last chance recovery: if row exists already, continue with that id.
existing_email = execute_query_single(
"SELECT id FROM email_messages WHERE message_id = %s",
(generated_message_id,),
) if generated_message_id else None
if existing_email:
email_id = existing_email["id"]
else:
warning_detail = str(insert_error or "email logging failed")
logger.error("❌ Email sent but no local email_id could be resolved for case %s", sag_id)
return {
"status": "sent",
"email_id": None,
"message": send_message,
"warning": f"Email sent but could not be logged locally: {warning_detail}",
}
if smtp_attachments and email_id:
from psycopg2 import Binary
for attachment in smtp_attachments:
execute_query(
"""
INSERT INTO email_attachments (
email_id, filename, content_type, size_bytes, file_path, content_data
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
try:
execute_query(
"""
INSERT INTO email_attachments (
email_id, filename, content_type, size_bytes, file_path, content_data
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
email_id,
attachment["filename"],
attachment["content_type"],
attachment.get("size") or len(attachment["content"]),
attachment.get("file_path"),
Binary(attachment["content"]),
),
)
except Exception as e:
logger.warning(
"⚠️ Could not persist outbound email attachment '%s' for email_id=%s: %s",
attachment.get("filename"),
email_id,
attachment["filename"],
attachment["content_type"],
attachment.get("size") or len(attachment["content"]),
attachment.get("file_path"),
Binary(attachment["content"]),
),
)
e,
)
execute_query(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(sag_id, email_id),
)
linked_ok = False
try:
execute_query(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(sag_id, email_id),
)
linked_ok = True
except Exception as e:
logger.warning("⚠️ Could not insert sag_emails link for case=%s email_id=%s: %s", sag_id, email_id, e)
if table_has_column("email_messages", "linked_case_id"):
try:
execute_query(
"UPDATE email_messages SET linked_case_id = %s WHERE id = %s",
(sag_id, email_id),
)
linked_ok = True
except Exception as nested_e:
logger.warning(
"⚠️ Fallback linked_case_id update also failed for case=%s email_id=%s: %s",
sag_id,
email_id,
nested_e,
)
sent_ts = datetime.now().isoformat()
outgoing_comment = (
@ -2743,14 +2877,63 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
f"{body_text}"
)
comment_row = execute_query_single(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
RETURNING kommentar_id, created_at
""",
(sag_id, 'Email Bot', outgoing_comment, True),
) or {}
comment_row = {}
try:
has_system_flag = table_has_column("sag_kommentarer", "er_system_besked")
attempted_errors = []
has_comment_id_col = table_has_column("sag_kommentarer", "kommentar_id")
has_id_col = table_has_column("sag_kommentarer", "id")
# Prefer the variant that matches the live schema to avoid noisy SQL errors in logs.
if has_comment_id_col:
returning_variants = ["kommentar_id", "id AS kommentar_id"]
elif has_id_col:
returning_variants = ["id AS kommentar_id", "kommentar_id"]
else:
returning_variants = ["kommentar_id", "id AS kommentar_id"]
if has_system_flag:
comment_variants = [
(
f"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
RETURNING {returning_expr}, created_at
""",
(sag_id, 'Email Bot', outgoing_comment, True),
)
for returning_expr in returning_variants
]
else:
comment_variants = [
(
f"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold)
VALUES (%s, %s, %s)
RETURNING {returning_expr}, created_at
""",
(sag_id, 'Email Bot', outgoing_comment),
)
for returning_expr in returning_variants
]
for variant_query, variant_params in comment_variants:
try:
comment_row = execute_query_single(variant_query, variant_params) or {}
if comment_row:
break
except Exception as variant_error:
attempted_errors.append(str(variant_error))
if not comment_row and attempted_errors:
logger.warning(
"⚠️ Outbound email sent but comment logging variants failed for case %s: %s",
sag_id,
" | ".join(attempted_errors),
)
except Exception as e:
logger.warning("⚠️ Outbound email sent but comment logging failed for case %s: %s", sag_id, e)
comment_created_at = comment_row.get("created_at")
if isinstance(comment_created_at, datetime):
@ -2759,17 +2942,21 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
comment_created_at = sent_ts
logger.info(
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, payload_thread_key=%s, stored_thread_key=%s, provider_thread_key=%s, recipients=%s)",
sag_id,
email_id,
payload.thread_email_id,
payload.thread_key,
effective_payload_thread_key,
thread_key,
provider_thread_key_normalized,
", ".join(to_addresses),
)
return {
"status": "sent",
"email_id": email_id,
"message": send_message,
"linked_to_case": linked_ok,
"warning": None if linked_ok else "Email sent, but automatic case-thread link fallback was required",
"comment": {
"kommentar_id": comment_row.get("kommentar_id"),
"forfatter": "Email Bot",

View File

@ -460,7 +460,7 @@
// Check for associated company (auto-select if single match)
try {
const response = await fetch(`/api/v1/contacts/${id}`);
const response = await fetch(`/api/v1/contacts/${id}`, { credentials: 'include' });
if (response.ok) {
const data = await response.json();
selectedContactsCompanies[id] = data.companies || [];
@ -530,7 +530,7 @@
if (telefoniPrefill.customerId && !selectedCustomer) {
try {
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`);
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`, { credentials: 'include' });
if (customerRes.ok) {
const customer = await customerRes.json();
const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`;
@ -543,7 +543,7 @@
if (telefoniPrefill.contactId) {
try {
const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`);
const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`, { credentials: 'include' });
if (!res.ok) return;
const c = await res.json();
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`;
@ -598,7 +598,7 @@
try {
const responses = await Promise.all(
contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`))
contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`, { credentials: 'include' }))
);
const datasets = await Promise.all(responses.map(r => r.ok ? r.json() : []));
const merged = new Map();
@ -686,6 +686,7 @@
try {
const response = await fetch('/api/v1/hardware/quick', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
@ -723,6 +724,7 @@
try {
const response = await fetch('/api/v1/customers', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() })
});
@ -738,6 +740,7 @@
const linkResponses = await Promise.all(contactIds.map(contactId =>
fetch(`/api/v1/contacts/${contactId}/companies`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: created.id, is_primary: false })
})
@ -772,6 +775,7 @@
};
const response = await fetch('/api/v1/contacts', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
@ -886,6 +890,7 @@
try {
const response = await fetch('/api/v1/sag', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@ -897,9 +902,7 @@
const contactPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'})
})
credentials: 'include',
);
await Promise.all(contactPromises);
@ -909,6 +912,7 @@
try {
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: result.id,
@ -925,6 +929,7 @@
const linkPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
})

File diff suppressed because it is too large Load Diff

View File

@ -27,19 +27,23 @@ async def search_contacts(q: str = Query(..., min_length=2)):
"""
Autocomplete search for contacts.
Returns list of {id, first_name, last_name, email}
Supports: first name, last name, email, combined "Fornavn Efternavn", phone, mobile.
"""
sql = """
SELECT id, first_name, last_name, email
FROM contacts
WHERE
(first_name ILIKE %s OR
last_name ILIKE %s OR
email ILIKE %s)
first_name ILIKE %s
OR last_name ILIKE %s
OR email ILIKE %s
OR CONCAT(first_name, ' ', last_name) ILIKE %s
OR phone ILIKE %s
OR mobile ILIKE %s
ORDER BY first_name ASC, last_name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term))
results = execute_query(sql, (term, term, term, term, term, term))
return results
@router.get("/search/hardware")

View File

@ -39,8 +39,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
contact_id = payload.contact_id
if not contact_id:
suffix8 = phone_suffix_8(payload.to)
contact = TelefoniService.find_contact_by_phone_suffix(suffix8)
contact = TelefoniService.find_contact_by_phone(payload.to)
contact_id = int(contact["id"]) if contact and contact.get("id") else None
if not contact_id:
@ -246,11 +245,10 @@ async def yealink_established(
break
ekstern_e164 = normalize_e164(ekstern_raw)
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
suffix8 = phone_suffix_8(ekstern_raw)
user_ids = TelefoniService.find_user_by_extension(local_extension)
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
kontakt = TelefoniService.find_contact_by_phone(ekstern_raw)
kontakt_id = kontakt.get("id") if kontakt else None
# Get extended contact details if we found a contact
@ -665,7 +663,13 @@ async def list_calls(
t.sag_id,
t.started_at,
t.ended_at,
t.duration_sec,
COALESCE(
t.duration_sec,
CASE
WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
ELSE NULL
END
) AS duration_sec,
t.created_at,
u.username,
u.full_name,

View File

@ -20,11 +20,17 @@ class TelefoniService:
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
@staticmethod
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
if not suffix8:
def find_contact_by_phone(number: Optional[str]) -> Optional[dict]:
"""Two-step lookup: full normalised number first, 8-digit suffix as fallback."""
from app.modules.telefoni.backend.utils import phone_digits_full, phone_suffix_8
full = phone_digits_full(number)
suffix = phone_suffix_8(number)
if not full and not suffix:
return None
query = """
_contact_cte = """
SELECT
c.id,
c.first_name,
@ -60,12 +66,36 @@ class TelefoniService:
WHERE t.kontakt_id = c.id
) AS last_call_at
FROM contacts c
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
LIMIT 1
"""
row = execute_query_single(query, (suffix8, suffix8))
row = None
# Step 1: exact full-digit match (strips country code first)
if full:
query_full = _contact_cte + """
WHERE regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g') LIKE %s
OR regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g') LIKE %s
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
LIMIT 1
"""
# Match ending with full digits (covers both with and without country code stored)
pattern = f"%{full}"
row = execute_query_single(query_full, (pattern, pattern))
if row:
logger.debug("📞 Phone lookup: full-digit match for %s → contact %s", number, row["id"])
# Step 2: 8-digit suffix fallback
if not row and suffix:
query_suffix = _contact_cte + """
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
LIMIT 1
"""
row = execute_query_single(query_suffix, (suffix, suffix))
if row:
logger.debug("📞 Phone lookup: suffix-8 fallback for %s → contact %s", number, row["id"])
if not row:
return None
return {
@ -75,6 +105,11 @@ class TelefoniService:
"company": row.get("company"),
}
@staticmethod
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
"""Deprecated: use find_contact_by_phone(). Kept for backward compatibility."""
return TelefoniService.find_contact_by_phone(suffix8)
@staticmethod
def upsert_call(
*,
@ -126,7 +161,13 @@ class TelefoniService:
"""
UPDATE telefoni_opkald
SET ended_at = NOW(),
duration_sec = %s
duration_sec = COALESCE(
%s,
CASE
WHEN started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - started_at))::int, 0)
ELSE NULL
END
)
WHERE callid = %s
RETURNING id
""",

View File

@ -46,6 +46,21 @@ def phone_suffix_8(number: Optional[str]) -> Optional[str]:
return d[-8:]
def phone_digits_full(number: Optional[str]) -> Optional[str]:
"""Return full digit string strip of any +45/0045 prefix for Danish numbers."""
if not number:
return None
d = digits_only(number)
if not d:
return None
# Strip leading 0045 or 45 prefix from 10-digit Danish numbers
if d.startswith("0045") and len(d) == 12:
return d[4:]
if d.startswith("45") and len(d) == 10:
return d[2:]
return d
def is_outbound_call(caller: Optional[str], local_extension: Optional[str]) -> bool:
caller_d = digits_only(caller)
local_d = digits_only(local_extension)

View File

@ -4,6 +4,8 @@ REST API endpoints for managing remote support sessions
"""
import logging
import json
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
@ -100,14 +102,14 @@ async def get_session_details(session_id: int):
s.created_by_user_id, s.created_at, s.updated_at,
c.first_name || ' ' || c.last_name as contact_name,
cust.name as customer_name,
sag.title as sag_title,
sag.titel as sag_title,
u.full_name as created_by_user_name,
s.device_info, s.metadata
FROM anydesk_sessions s
LEFT JOIN contacts c ON s.contact_id = c.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
LEFT JOIN users u ON s.created_by_user_id = u.id
LEFT JOIN users u ON s.created_by_user_id = u.user_id
WHERE s.id = %s
"""
@ -149,6 +151,197 @@ async def end_remote_session(session_id: int):
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/anydesk/sessions/{session_id}", tags=["Remote Support"])
async def update_session(session_id: int, data: dict):
"""
Update a session assign/re-assign to a sag, contact, or add notes.
Accepted fields: sag_id, contact_id, customer_id, notes, status
"""
try:
allowed = {"sag_id", "contact_id", "customer_id", "notes", "status"}
updates = {k: v for k, v in data.items() if k in allowed}
if not updates:
raise HTTPException(status_code=400, detail="No valid fields to update")
# Verify session exists
existing = execute_query("SELECT id FROM anydesk_sessions WHERE id = %s", (session_id,))
if not existing:
raise HTTPException(status_code=404, detail="Session not found")
# Verify sag exists if provided
if "sag_id" in updates and updates["sag_id"] is not None:
sag = execute_query("SELECT id FROM sag_sager WHERE id = %s", (updates["sag_id"],))
if not sag:
raise HTTPException(status_code=404, detail="Case not found")
set_clauses = ", ".join([f"{k} = %s" for k in updates])
params = list(updates.values()) + [session_id]
query = f"""
UPDATE anydesk_sessions
SET {set_clauses}, updated_at = NOW()
WHERE id = %s
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id,
session_link, status, started_at, ended_at, duration_minutes,
created_by_user_id, created_at, updated_at
"""
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=500, detail="Update failed")
logger.info(f"✅ Updated AnyDesk session {session_id}: {list(updates.keys())}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating session: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/anydesk/register-manual-session", tags=["Remote Support"])
async def register_manual_session(data: dict):
"""
Register a manual AnyDesk support session directly on a case.
Expected payload:
- customer_id (required)
- sag_id (required)
- anydesk_id (required)
- assisted_device (required)
- device_type (required, e.g. placebo/desktop/server)
- contact_id (optional)
- notes (optional)
- created_by_user_id (optional)
"""
try:
customer_id = data.get("customer_id")
sag_id = data.get("sag_id")
contact_id = data.get("contact_id")
created_by_user_id = data.get("created_by_user_id")
anydesk_id = str(data.get("anydesk_id") or "").strip()
assisted_device = str(data.get("assisted_device") or "").strip()
device_type = str(data.get("device_type") or "placebo").strip().lower()
notes = str(data.get("notes") or "").strip()
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required")
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
if not anydesk_id:
raise HTTPException(status_code=400, detail="anydesk_id is required")
if not assisted_device:
raise HTTPException(status_code=400, detail="assisted_device is required")
customer = execute_query("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
sag = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not sag:
raise HTTPException(status_code=404, detail="Case not found")
if contact_id:
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
manual_external_id = f"manual-{uuid4().hex[:12]}"
is_placeholder = device_type in {"placebo", "placeholder", "ukendt", "unknown"}
device_info = {
"to_id": anydesk_id,
"customer_machine_id": anydesk_id,
"assisted_device_name": assisted_device,
"assisted_device_type": device_type,
"is_placeholder_device": is_placeholder,
"source": "manual_case_registration"
}
metadata = {
"notes": notes,
"source": "manual_case_registration"
}
insert_q = """
INSERT INTO anydesk_sessions (
anydesk_session_id,
contact_id,
customer_id,
sag_id,
session_link,
device_info,
created_by_user_id,
started_at,
ended_at,
duration_minutes,
status,
metadata,
created_at,
updated_at
)
VALUES (
%s,
%s,
%s,
%s,
NULL,
%s::jsonb,
%s,
NOW(),
NOW(),
0,
'completed',
%s::jsonb,
NOW(),
NOW()
)
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at
"""
created = execute_query(
insert_q,
(
manual_external_id,
contact_id,
customer_id,
sag_id,
json.dumps(device_info),
created_by_user_id,
json.dumps(metadata),
),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to register session")
comment_lines = [
f"🖥️ AnyDesk session registreret manuelt (AnyDesk ID: {anydesk_id})",
f"Enhed: {assisted_device}",
f"Type: {device_type}",
]
if notes:
comment_lines.append(f"Notat: {notes}")
if is_placeholder:
comment_lines.append("Info: Enhedstype er placeholder og kan linkes til hardware senere.")
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, "System", "\n".join(comment_lines), True),
)
logger.info("✅ Manual AnyDesk session registered for case %s", sag_id)
return {"ok": True, "session": created[0]}
except HTTPException:
raise
except Exception as e:
logger.error("Error registering manual AnyDesk session: %s", str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
async def get_session_history(
contact_id: Optional[int] = None,
@ -170,13 +363,6 @@ async def get_session_history(
- **offset**: Pagination offset
"""
try:
if not any([contact_id, customer_id, sag_id]):
raise HTTPException(
status_code=400,
detail="At least one filter (contact_id, customer_id, or sag_id) is required"
)
# Validate limit
if limit > 100:
limit = 100
@ -369,11 +555,268 @@ async def anydesk_health_check():
Returns configuration status, API connectivity, and last sync time
"""
creds = anydesk_service._get_credentials()
return JSONResponse(content={
"service": "AnyDesk Remote Support",
"status": "operational",
"configured": bool(anydesk_service.api_token and anydesk_service.license_id),
"dry_run_mode": anydesk_service.dry_run,
"read_only_mode": anydesk_service.read_only,
"configured": bool(creds["api_token"] and creds["license_id"]),
"dry_run_mode": creds["dry_run"],
"read_only_mode": creds["read_only"],
"auto_start_enabled": anydesk_service.auto_start
})
@router.post("/anydesk/fetch-from-api", tags=["Remote Support"])
async def fetch_sessions_from_anydesk(
days: int = 30,
limit: int = 1000,
after: Optional[str] = None,
before: Optional[str] = None,
):
"""
Pull session log from the live AnyDesk REST API and import into local DB.
- **days**: How many days back to fetch (default 30)
- **limit**: Max entries to fetch (default 1000)
- **after**: Override start as ISO-8601 timestamp (e.g. 2024-01-01T00:00:00Z)
- **before**: Override end as ISO-8601 timestamp
Requires `dry_run=false` in AnyDesk settings.
Auth uses HMAC-SHA1 (AnyDesk native format), not Bearer token.
Returns count of newly imported and updated sessions.
"""
try:
result = await anydesk_service.fetch_sessions_from_api(
days=days,
limit=limit,
after=after,
before=before,
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return JSONResponse(content=result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching from AnyDesk API: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# =====================================================
# Sessions Dashboard Endpoints
# =====================================================
@router.get("/anydesk/sessions-overview", tags=["Remote Support"])
async def sessions_overview(
days: int = 90,
unregistered_only: bool = False,
limit: int = 200,
offset: int = 0,
):
"""
Enriched session list for the dashboard page.
Joins hardware_assets (via remote_id/anydesk_id), contacts, customers, sag.
"""
try:
where = "WHERE s.started_at >= NOW() - INTERVAL '%s days'" % int(days)
if unregistered_only:
where += " AND s.sag_id IS NULL AND s.contact_id IS NULL AND s.hardware_asset_id IS NULL"
query = f"""
SELECT
s.id,
s.anydesk_session_id,
s.started_at,
s.ended_at,
s.duration_minutes,
s.status,
s.notes,
-- linked hardware
s.hardware_asset_id,
ha.brand AS hw_brand,
ha.model AS hw_model,
ha.anydesk_id AS hw_anydesk_id,
ha.current_owner_customer_id AS hw_customer_id,
-- linked contact
s.contact_id,
c.first_name || ' ' || c.last_name AS contact_name,
c.email AS contact_email,
-- linked customer
s.customer_id,
cust.name AS customer_name,
-- linked sag
s.sag_id,
sag.titel AS sag_titel,
sag.status AS sag_status,
-- raw remote_id from import
(s.device_info->>'remote_id')::TEXT AS remote_id,
(s.device_info->>'remote_alias')::TEXT AS remote_alias,
(s.device_info->>'from_id')::TEXT AS technician_id,
(s.device_info->>'to_id')::TEXT AS customer_machine_id,
(s.device_info->>'local_alias')::TEXT AS customer_alias,
-- technician resolved from user_anydesk_ids
tech_u.user_id AS tech_user_id,
COALESCE(tech_u.full_name, tech_u.username) AS tech_name
FROM anydesk_sessions s
LEFT JOIN hardware_assets ha ON s.hardware_asset_id = ha.id
LEFT JOIN contacts c ON s.contact_id = c.id
LEFT JOIN customers cust ON s.customer_id = cust.id
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
LEFT JOIN user_anydesk_ids uad
ON regexp_replace(COALESCE(uad.anydesk_id, ''), '[^0-9]', '', 'g') =
regexp_replace(COALESCE((s.device_info->>'from_id')::TEXT, ''), '[^0-9]', '', 'g')
LEFT JOIN users tech_u ON tech_u.user_id = uad.user_id
{where}
ORDER BY s.started_at DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
rows = execute_query(query)
# count
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
total = (execute_query(count_q) or [{"total": 0}])[0]["total"]
sessions = []
for r in (rows or []):
sessions.append({
"id": r["id"],
"anydesk_session_id": r["anydesk_session_id"],
"started_at": str(r["started_at"]) if r["started_at"] else None,
"ended_at": str(r["ended_at"]) if r["ended_at"] else None,
"duration_minutes": r["duration_minutes"],
"status": r["status"],
"notes": r["notes"],
"remote_id": r["remote_id"],
"remote_alias": r["remote_alias"],
"technician_id": r["technician_id"], # from.cid — teknikkerens maskine
"technician_name": r["tech_name"], # resolved from user_anydesk_ids
"customer_machine_id": r["customer_machine_id"], # to.cid — kundens maskine
"customer_alias": r["customer_alias"],
"hardware": {
"id": r["hardware_asset_id"],
"brand": r["hw_brand"],
"model": r["hw_model"],
"anydesk_id": r["hw_anydesk_id"],
"customer_id": r["hw_customer_id"],
} if r["hardware_asset_id"] else None,
"contact": {
"id": r["contact_id"],
"name": r["contact_name"],
"email": r["contact_email"],
} if r["contact_id"] else None,
"customer": {
"id": r["customer_id"],
"name": r["customer_name"],
} if r["customer_id"] else None,
"sag": {
"id": r["sag_id"],
"titel": r["sag_titel"],
"status": r["sag_status"],
} if r["sag_id"] else None,
})
return JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset})
except Exception as e:
logger.error(f"Error in sessions_overview: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/anydesk/auto-link", tags=["Remote Support"])
async def auto_link_sessions():
"""
Auto-link unlinked sessions to hardware_assets via anydesk_id match,
and carry over contact/customer from the hardware asset.
Returns count of newly linked sessions.
"""
try:
linked = 0
# Match sessions to hardware_assets where to_id (customer machine) = anydesk_id
# NOTE: from.cid is the TECHNICIAN's machine, to.cid is the CUSTOMER's machine
result = execute_query("""
UPDATE anydesk_sessions s
SET
hardware_asset_id = ha.id,
customer_id = COALESCE(s.customer_id, ha.current_owner_customer_id),
updated_at = NOW()
FROM hardware_assets ha
WHERE ha.anydesk_id IS NOT NULL
AND ha.anydesk_id != ''
AND (
(s.device_info->>'to_id') = ha.anydesk_id
OR
-- fallback: older imports without to_id try remote_id only if it differs from technicians' known IDs
(s.device_info->>'to_id' IS NULL AND (s.device_info->>'remote_id') = ha.anydesk_id)
)
AND s.hardware_asset_id IS NULL
RETURNING s.id
""")
linked = len(result) if result else 0
logger.info(f"✅ Auto-linked {linked} AnyDesk sessions to hardware assets")
return JSONResponse(content={"linked": linked})
except Exception as e:
logger.error(f"Error in auto_link_sessions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/anydesk/sessions/{session_id}/link", tags=["Remote Support"])
async def link_session(
session_id: int,
sag_id: Optional[int] = None,
contact_id: Optional[int] = None,
customer_id: Optional[int] = None,
hardware_asset_id: Optional[int] = None,
notes: Optional[str] = None,
):
"""
Manually link a session to sag, contact, customer, hardware, or add notes.
"""
try:
sets = ["updated_at = NOW()"]
params = []
if sag_id is not None:
sets.append("sag_id = %s"); params.append(sag_id)
if contact_id is not None:
sets.append("contact_id = %s"); params.append(contact_id)
if customer_id is not None:
sets.append("customer_id = %s"); params.append(customer_id)
if hardware_asset_id is not None:
sets.append("hardware_asset_id = %s"); params.append(hardware_asset_id)
if notes is not None:
sets.append("notes = %s"); params.append(notes)
if len(sets) == 1:
return JSONResponse(content={"message": "no changes"})
params.append(session_id)
execute_query(
f"UPDATE anydesk_sessions SET {', '.join(sets)} WHERE id = %s",
tuple(params)
)
return JSONResponse(content={"ok": True})
except Exception as e:
logger.error(f"Error linking session {session_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/anydesk/hardware-assets", tags=["Remote Support"])
async def anydesk_hardware_list():
"""List all hardware assets that have an anydesk_id (for linking dropdown)"""
try:
rows = execute_query("""
SELECT ha.id, ha.brand, ha.model, ha.anydesk_id, ha.serial_number,
ha.current_owner_customer_id AS customer_id, cust.name AS customer_name
FROM hardware_assets ha
LEFT JOIN customers cust ON ha.current_owner_customer_id = cust.id
WHERE ha.deleted_at IS NULL
ORDER BY ha.brand, ha.model
""")
return JSONResponse(content={"assets": rows or []})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -5,9 +5,14 @@ Handles integration with AnyDesk API for remote session management
import logging
import json
import hashlib
import hmac
import base64
import time
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import httpx
import aiohttp
from app.core.config import settings
from app.core.database import execute_query
@ -23,79 +28,117 @@ class AnyDeskService:
Respects safety switches: READ_ONLY and DRY_RUN
"""
BASE_URL = "https://api.anydesk.com"
BASE_URL = "https://v1.api.anydesk.com:8081"
def __init__(self):
self.api_token = settings.ANYDESK_API_TOKEN
self.license_id = settings.ANYDESK_LICENSE_ID
self.read_only = settings.ANYDESK_READ_ONLY
self.dry_run = settings.ANYDESK_DRY_RUN
self.timeout = settings.ANYDESK_TIMEOUT_SECONDS
# Credentials loaded lazily from DB at call-time (via _get_credentials)
# Fall back to .env values if DB has nothing
self._timeout = settings.ANYDESK_TIMEOUT_SECONDS
self.auto_start = settings.ANYDESK_AUTO_START_SESSION
if not self.api_token or not self.license_id:
logger.warning("⚠️ AnyDesk credentials not configured - service disabled")
def _get_credentials(self) -> Dict[str, Any]:
"""Load credentials from DB settings table, fallback to .env"""
try:
rows = execute_query(
"SELECT key, value FROM settings WHERE key LIKE 'anydesk_%'",
)
db = {r["key"]: r["value"] for r in rows} if rows else {}
except Exception:
db = {}
def _bool(val, default: bool) -> bool:
if val is None:
return default
return str(val).lower() in ("true", "1", "yes")
return {
"api_token": db.get("anydesk_api_token") or settings.ANYDESK_API_TOKEN or "",
"license_id": db.get("anydesk_license_id") or settings.ANYDESK_LICENSE_ID or "",
"read_only": _bool(db.get("anydesk_read_only"), settings.ANYDESK_READ_ONLY),
"dry_run": _bool(db.get("anydesk_dry_run"), settings.ANYDESK_DRY_RUN),
}
@property
def timeout(self):
return self._timeout
def _generate_auth_header(self, resource: str, content: str = "", method: str = "GET") -> str:
"""
AnyDesk HMAC-SHA1 auth header.
Format: AD {license_id}:{timestamp}:{signature}
"""
creds = self._get_credentials()
sha1 = hashlib.sha1()
sha1.update(content.encode("utf-8"))
content_hash = base64.b64encode(sha1.digest()).decode("utf-8")
timestamp = str(int(time.time()))
request_string = f"{method}\n{resource}\n{timestamp}\n{content_hash}"
sig = hmac.new(
creds["api_token"].encode("utf-8"),
request_string.encode("utf-8"),
hashlib.sha1,
).digest()
token = base64.b64encode(sig).decode("utf-8")
return f"AD {creds['license_id']}:{timestamp}:{token}"
def _check_enabled(self) -> bool:
"""Check if AnyDesk is properly configured"""
if not self.api_token or not self.license_id:
creds = self._get_credentials()
if not creds["api_token"] or not creds["license_id"]:
logger.warning("AnyDesk service not configured (missing credentials)")
return False
return True
async def _api_call(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
"""
Make HTTP call to AnyDesk API
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (e.g., "/v1/sessions")
data: Request body data
Returns:
Response JSON dictionary
"""
if not self._check_enabled():
return {"error": "AnyDesk not configured"}
# Log the intent
creds = self._get_credentials()
dry_run = creds["dry_run"]
read_only = creds["read_only"]
log_msg = f"🔗 AnyDesk API: {method} {endpoint}"
if data:
log_msg += f" | Data: {json.dumps(data, indent=2)}"
logger.info(log_msg)
# DRY RUN: Don't actually call API
if self.dry_run:
if dry_run:
logger.warning("⚠️ DRY_RUN=true: Simulating API response (no actual call)")
return self._simulate_response(method, endpoint, data)
# READ ONLY: Allow gets but not mutations
if self.read_only and method != "GET":
if read_only and method != "GET":
logger.warning(f"🔒 READ_ONLY=true: Blocking {method} request")
return {"error": "Read-only mode: mutations disabled"}
body_str = json.dumps(data) if data else ""
auth_header = self._generate_auth_header(endpoint, body_str, method)
headers = {
"Authorization": auth_header,
"Content-Type": "application/json",
}
url = f"{self.BASE_URL}{endpoint}"
try:
headers = {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
}
url = f"{self.BASE_URL}{endpoint}"
async with httpx.AsyncClient(timeout=self.timeout) as client:
if method == "GET":
response = await client.get(url, headers=headers)
elif method == "POST":
response = await client.post(url, headers=headers, json=data)
elif method == "PUT":
response = await client.put(url, headers=headers, json=data)
elif method == "DELETE":
response = await client.delete(url, headers=headers)
else:
return {"error": f"Unsupported method: {method}"}
response.raise_for_status()
return response.json()
async with aiohttp.ClientSession() as session:
kwargs = {"headers": headers, "timeout": aiohttp.ClientTimeout(total=self.timeout)}
if data:
kwargs["json"] = data
async with getattr(session, method.lower())(url, **kwargs) as response:
response_text = await response.text()
logger.info(f"📡 AnyDesk API {response.status}: {response_text[:200]}")
if response.status == 200:
try:
return await response.json(content_type=None)
except Exception:
return {"raw": response_text}
elif response.status == 401:
logger.error(f"❌ AnyDesk auth failed — check license_id + api_token")
return {"error": f"Unauthorized (401): {response_text[:200]}"}
else:
logger.error(f"❌ AnyDesk API error {response.status}: {response_text[:300]}")
return {"error": f"HTTP {response.status}: {response_text[:300]}"}
except httpx.HTTPError as e:
logger.error(f"❌ AnyDesk API error: {str(e)}")
@ -156,11 +199,13 @@ class AnyDeskService:
Returns:
Session data with session_id, link, access_code, etc.
"""
creds = self._get_credentials()
# Prepare session data
session_data = {
"name": f"BMC Support - Customer {customer_id}",
"description": description or f"Support session for customer {customer_id}",
"license_id": self.license_id,
"license_id": creds["license_id"],
"auto_accept": True # Auto-accept connection requests
}
@ -189,7 +234,7 @@ class AnyDeskService:
device_info = {
"created_via": "api",
"auto_start": self.auto_start,
"dry_run_mode": self.dry_run
"dry_run_mode": creds["dry_run"]
}
metadata = {
@ -385,7 +430,7 @@ class AnyDeskService:
s.created_by_user_id, s.created_at, s.updated_at,
c.first_name || ' ' || c.last_name as contact_name,
cust.name as customer_name,
sag.title as sag_title,
sag.titel as sag_title,
u.full_name as created_by_user_name,
s.device_info, s.metadata
FROM anydesk_sessions s
@ -422,3 +467,113 @@ class AnyDeskService:
except Exception as e:
logger.error(f"Error fetching session history: {str(e)}")
return {"error": str(e), "sessions": []}
async def fetch_sessions_from_api(
self,
days: int = 30,
limit: int = 1000,
after: Optional[str] = None,
before: Optional[str] = None,
) -> Dict[str, Any]:
"""
Pull session log from AnyDesk REST API and upsert into local DB.
AnyDesk API: GET /sessions?from=UNIX&to=UNIX&limit=N
Auth: HMAC-SHA1 signature (not Bearer token)
Returns summary of imported/updated records.
"""
end_ts = int(time.time())
start_ts = end_ts - (days * 86400)
# Allow ISO override
if after:
try:
start_ts = int(datetime.fromisoformat(after.rstrip("Z")).timestamp())
except Exception:
pass
if before:
try:
end_ts = int(datetime.fromisoformat(before.rstrip("Z")).timestamp())
except Exception:
pass
qs = f"from={start_ts}&to={end_ts}&limit={limit}"
result = await self._api_call("GET", f"/sessions?{qs}")
if "error" in result:
return result
# AnyDesk returns { "list": [...] }
entries = result.get("list", result if isinstance(result, list) else [])
imported = 0
updated = 0
errors = []
for i, entry in enumerate(entries):
if i < 3:
logger.info(f"📊 AnyDesk session sample: {entry}")
try:
session_id = str(entry.get("sid") or "")
if not session_id:
continue
# AnyDesk timestamps are unix integers
started_raw = entry.get("start-time")
ended_raw = entry.get("end-time")
started = datetime.utcfromtimestamp(started_raw) if started_raw else None
ended = datetime.utcfromtimestamp(ended_raw) if ended_raw else None
duration_s = entry.get("duration") or 0
duration_min = round(int(duration_s) / 60, 1) if duration_s else None
remote_alias = entry.get("from", {}).get("alias") if isinstance(entry.get("from"), dict) else None
from_id = str(entry.get("from", {}).get("cid") or "") if isinstance(entry.get("from"), dict) else None # technician machine
to_id = str(entry.get("to", {}).get("cid") or "") if isinstance(entry.get("to"), dict) else None # customer machine
local_alias = entry.get("to", {}).get("alias") if isinstance(entry.get("to"), dict) else None
status = "active" if entry.get("active") else "completed"
device_info = json.dumps({
"remote_alias": remote_alias, # technician alias (from)
"remote_id": from_id, # technician machine CID (from.cid) — kept for compat
"from_id": from_id, # technician machine CID
"to_id": to_id, # customer machine CID ← use for hardware linking
"local_alias": local_alias, # customer alias (to)
"imported_from_api": True,
})
metadata = json.dumps({"raw": entry})
# Upsert: insert or update on anydesk_session_id
check = execute_query(
"SELECT id FROM anydesk_sessions WHERE anydesk_session_id = %s",
(session_id,)
)
if check:
execute_query(
"""UPDATE anydesk_sessions
SET status=%s, ended_at=%s, duration_minutes=%s,
device_info=%s, metadata=%s, updated_at=NOW()
WHERE anydesk_session_id=%s""",
(status, ended, duration_min, device_info, metadata, session_id)
)
updated += 1
else:
execute_query(
"""INSERT INTO anydesk_sessions
(anydesk_session_id, status, started_at, ended_at,
duration_minutes, device_info, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(session_id, status, started, ended, duration_min, device_info, metadata)
)
imported += 1
except Exception as exc:
errors.append(str(exc))
logger.warning(f"⚠️ Could not import entry: {exc}")
logger.info(f"✅ AnyDesk import done: {imported} new, {updated} updated, {len(errors)} errors")
return {
"imported": imported,
"updated": updated,
"total_from_api": len(entries),
"errors": errors,
}

View File

@ -731,12 +731,9 @@ class EmailService:
Priority:
1) First References token (root message id)
2) In-Reply-To
3) Message-ID
3) Explicit provider thread key (e.g. Graph conversationId)
4) Message-ID
"""
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
if explicit_thread_key:
return explicit_thread_key
reference_ids = self._extract_reference_ids(email_data.get("email_references"))
if reference_ids:
return reference_ids[0]
@ -745,6 +742,10 @@ class EmailService:
if in_reply_to:
return in_reply_to
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
if explicit_thread_key:
return explicit_thread_key
return self._normalize_message_id_value(email_data.get("message_id"))
def _parse_email_date(self, date_str: str) -> datetime:

View File

@ -299,10 +299,7 @@ class EmailWorkflowService:
return list(dict.fromkeys(tokens))
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
"""Derive stable conversation key: root References -> In-Reply-To -> Message-ID."""
explicit = self._normalize_message_id(email_data.get('thread_key'))
if explicit:
return explicit
"""Derive stable conversation key: root References -> In-Reply-To -> explicit -> Message-ID."""
ref_ids = self._extract_reference_message_ids(email_data.get('email_references'))
if ref_ids:
@ -312,6 +309,10 @@ class EmailWorkflowService:
if in_reply_to:
return in_reply_to
explicit = self._normalize_message_id(email_data.get('thread_key'))
if explicit:
return explicit
return self._normalize_message_id(email_data.get('message_id'))
def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]:
@ -326,7 +327,7 @@ class EmailWorkflowService:
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE em.deleted_at IS NULL
AND LOWER(TRIM(COALESCE(em.thread_key, ''))) = %s
AND LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
ORDER BY se.created_at DESC
LIMIT 1
""",

View File

@ -221,6 +221,27 @@ async def update_setting(key: str, setting: SettingUpdate):
)
)
# Mission camera settings may not exist on older hubs before migration.
# AnyDesk settings may not exist on older hubs — auto-create on first save
_anydesk_keys = {
"anydesk_api_token": ("integrations", "AnyDesk API token", "string", False),
"anydesk_license_id": ("integrations", "AnyDesk license ID", "string", False),
"anydesk_read_only": ("integrations", "AnyDesk read-only mode", "boolean", True),
"anydesk_dry_run": ("integrations", "AnyDesk dry-run mode", "boolean", True),
}
if not result and key in _anydesk_keys:
category, description, value_type, is_public = _anydesk_keys[key]
result = execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
RETURNING *
""",
(key, setting.value, category, description, value_type, is_public),
)
# Mission camera settings may not exist on older hubs before migration.
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
defaults = {

View File

@ -204,6 +204,61 @@
</button>
</div>
</div>
<!-- AnyDesk -->
<div class="card p-4 mt-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-display" style="font-size:1.4rem;color:#0f4c75"></i>
<h5 class="mb-0 fw-bold">AnyDesk Remote Support</h5>
</div>
<a href="https://my.anydesk.com" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-box-arrow-up-right me-1"></i>AnyDesk Admin Portal
</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">API Token <span class="text-danger">*</span></label>
<input type="password" class="form-control font-monospace" id="anydeskApiToken" placeholder="Paste AnyDesk API token..." autocomplete="off">
<div class="form-text">Hentes fra AnyDesk admin panel → <strong>API → Access tokens</strong></div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">License ID</label>
<input type="text" class="form-control font-monospace" id="anydeskLicenseId" placeholder="fx a1b2c3d4-..." autocomplete="off">
<div class="form-text">AnyDesk licens-ID (UUID format)</div>
</div>
<div class="col-12">
<div class="d-flex gap-4 flex-wrap">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="anydeskReadOnly" role="switch">
<label class="form-check-label" for="anydeskReadOnly">
<span class="fw-semibold">Read-only mode</span>
<span class="text-muted small d-block">Blokerer alle muterende API-kald</span>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="anydeskDryRun" role="switch">
<label class="form-check-label" for="anydeskDryRun">
<span class="fw-semibold">Dry-run mode</span>
<span class="text-muted small d-block">Logger uden at kalde AnyDesk API</span>
</label>
</div>
</div>
</div>
<div class="col-12">
<div class="alert alert-warning py-2 mb-0 small" id="anydeskSafetyAlert" style="display:none!important">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Advarsel:</strong> Både read-only og dry-run er deaktiveret. AnyDesk vil foretage rigtige API-kald.
</div>
</div>
</div>
<div class="d-flex align-items-center gap-3 mt-4">
<button class="btn btn-primary" onclick="saveAnydeskSettings()">
<i class="bi bi-save me-2"></i>Gem AnyDesk-indstillinger
</button>
<span id="anydeskSaveStatus" class="small text-muted"></span>
</div>
</div>
</div>
<!-- Telefoni -->
@ -1990,6 +2045,7 @@ async function loadSettings() {
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances();
await loadAnydeskSettings();
} catch (error) {
console.error('Error loading settings:', error);
}
@ -2031,6 +2087,81 @@ function displaySettingsByCategory() {
displaySettings('systemSettings', categories.system);
}
async function loadAnydeskSettings() {
const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
try {
const results = await Promise.allSettled(
keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null))
);
const vals = {};
results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; });
if (vals.anydesk_api_token) document.getElementById('anydeskApiToken').value = vals.anydesk_api_token;
if (vals.anydesk_license_id) document.getElementById('anydeskLicenseId').value = vals.anydesk_license_id;
document.getElementById('anydeskReadOnly').checked = vals.anydesk_read_only === 'true' || vals.anydesk_read_only === undefined;
document.getElementById('anydeskDryRun').checked = vals.anydesk_dry_run === 'true' || vals.anydesk_dry_run === undefined;
updateAnydeskSafetyAlert();
} catch (e) {
console.warn('AnyDesk settings load failed:', e);
}
}
function updateAnydeskSafetyAlert() {
const ro = document.getElementById('anydeskReadOnly')?.checked;
const dr = document.getElementById('anydeskDryRun')?.checked;
const alert = document.getElementById('anydeskSafetyAlert');
if (alert) alert.style.display = (!ro && !dr) ? '' : 'none';
}
document.addEventListener('change', e => {
if (e.target.id === 'anydeskReadOnly' || e.target.id === 'anydeskDryRun') updateAnydeskSafetyAlert();
});
async function saveAnydeskSettings() {
const token = document.getElementById('anydeskApiToken').value.trim();
const licenseId = document.getElementById('anydeskLicenseId').value.trim();
const readOnly = document.getElementById('anydeskReadOnly').checked;
const dryRun = document.getElementById('anydeskDryRun').checked;
const statusEl = document.getElementById('anydeskSaveStatus');
statusEl.textContent = 'Gemmer...';
statusEl.className = 'small text-muted';
const upsert = async (key, value, category, description) => {
// Try PUT first, fall back to POST (create) if 404
const putRes = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(value) })
});
if (putRes.status === 404) {
await fetch('/api/v1/settings', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value: String(value), category, description, value_type: 'string', is_public: false })
});
} else if (!putRes.ok) {
throw new Error(`Fejl ved gem af ${key}`);
}
};
try {
const saves = [
upsert('anydesk_read_only', readOnly, 'integrations', 'AnyDesk read-only mode'),
upsert('anydesk_dry_run', dryRun, 'integrations', 'AnyDesk dry-run mode'),
];
if (token) saves.push(upsert('anydesk_api_token', token, 'integrations', 'AnyDesk API token'));
if (licenseId) saves.push(upsert('anydesk_license_id', licenseId, 'integrations', 'AnyDesk license ID'));
await Promise.all(saves);
statusEl.textContent = '✅ Gemt';
statusEl.className = 'small text-success';
setTimeout(() => statusEl.textContent = '', 3000);
updateAnydeskSafetyAlert();
} catch (err) {
statusEl.textContent = '❌ ' + err.message;
statusEl.className = 'small text-danger';
}
}
async function loadNextcloudInstances() {
try {
const response = await fetch('/api/v1/nextcloud/instances');

View File

@ -247,6 +247,7 @@
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
@ -1112,8 +1113,43 @@
<div class="tab-content" id="profileTabsContent">
<div class="tab-pane fade show active" id="profile-overview" role="tabpanel" tabindex="0">
<div class="alert alert-info small mb-0">
Profilinformation hentes fra din konto. Flere felter kan tilføjes her senere.
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Fuldt navn</label>
<input type="text" class="form-control" id="prof_full_name" placeholder="Dit navn">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Titel / rolle</label>
<input type="text" class="form-control" id="prof_title" placeholder="f.eks. Teknikker">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Mobilnummer</label>
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
</div>
<div class="col-12">
<label class="form-label fw-semibold">
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
</label>
<div class="form-text mb-2">Tilføj alle maskiner du bruger som teknikker — bruges til automatisk at genkende dig i remote sessions.</div>
<div id="prof-anydesk-chips" class="d-flex flex-wrap gap-2 mb-2"></div>
<div class="input-group" style="max-width:400px">
<input type="text" class="form-control font-monospace" id="prof_anydesk_new_id"
placeholder="AnyDesk ID (tal)" autocomplete="off"
onkeydown="if(event.key==='Enter'){event.preventDefault();addAnyDeskId()}">
<input type="text" class="form-control" id="prof_anydesk_new_label"
placeholder="Navn (valgfri, f.eks. Laptop)" style="max-width:160px"
onkeydown="if(event.key==='Enter'){event.preventDefault();addAnyDeskId()}">
<button class="btn btn-outline-primary" type="button" onclick="addAnyDeskId()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<div class="col-12 pt-1">
<button class="btn btn-primary btn-sm" onclick="saveUserProfile()">
<i class="bi bi-check-lg me-1"></i>Gem profil
</button>
<span id="prof-save-status" class="ms-2 small text-success" style="display:none">Gemt ✓</span>
</div>
</div>
</div>
@ -1297,12 +1333,94 @@
}
}
async function loadUserProfile() {
try {
const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' });
if (!res.ok) return;
const p = await res.json();
document.getElementById('prof_full_name').value = p.full_name || '';
document.getElementById('prof_title').value = p.title || '';
document.getElementById('prof_phone').value = p.phone || '';
} catch (e) { console.error('Failed to load profile', e); }
loadAnyDeskChips();
}
async function loadAnyDeskChips() {
try {
const res = await fetch('/api/v1/auth/me/anydesk-ids', { credentials: 'include' });
if (!res.ok) return;
const { ids } = await res.json();
const box = document.getElementById('prof-anydesk-chips');
box.innerHTML = ids.length
? ids.map(entry => `
<span class="badge d-inline-flex align-items-center gap-1 fs-6 fw-normal"
style="background:rgba(15,76,117,0.1);color:#0f4c75;border:1px solid rgba(15,76,117,0.25);padding:.35rem .7rem;border-radius:6px">
<i class="bi bi-display" style="font-size:.8rem"></i>
<code style="font-size:.85rem;background:none;color:inherit">${entry.anydesk_id}</code>
${entry.label ? `<span style="opacity:.7;font-size:.8rem">— ${entry.label}</span>` : ''}
<button type="button" onclick="removeAnyDeskId(${entry.id})"
style="background:none;border:none;cursor:pointer;opacity:.6;padding:0 0 0 2px;line-height:1;color:inherit"
title="Fjern">&times;</button>
</span>`).join('')
: '<span class="text-secondary small">Ingen IDs tilføjet endnu</span>';
} catch(e) { /* silent */ }
}
async function addAnyDeskId() {
const id = document.getElementById('prof_anydesk_new_id').value.trim();
const label = document.getElementById('prof_anydesk_new_label').value.trim();
if (!id) return;
try {
const res = await fetch('/api/v1/auth/me/anydesk-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ anydesk_id: id, label: label || null })
});
if (!res.ok) { const e = await res.json(); alert(e.detail || 'Fejl'); return; }
document.getElementById('prof_anydesk_new_id').value = '';
document.getElementById('prof_anydesk_new_label').value = '';
loadAnyDeskChips();
} catch(e) { alert('Fejl: ' + e.message); }
}
async function removeAnyDeskId(entryId) {
try {
const res = await fetch(`/api/v1/auth/me/anydesk-ids/${entryId}`, {
method: 'DELETE', credentials: 'include'
});
if (!res.ok) throw new Error('Fejl');
loadAnyDeskChips();
} catch(e) { alert('Fejl: ' + e.message); }
}
async function saveUserProfile() {
const payload = {
full_name: document.getElementById('prof_full_name').value || null,
title: document.getElementById('prof_title').value || null,
phone: document.getElementById('prof_phone').value || null,
};
try {
const res = await fetch('/api/v1/auth/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
const statusEl = document.getElementById('prof-save-status');
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 3000);
} catch (e) { alert('Fejl: ' + e.message); }
}
document.addEventListener('DOMContentLoaded', () => {
const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => {
loadReminderPreferences();
loadProfileReminders();
loadUserProfile();
});
}
});

View File

@ -99,7 +99,7 @@ class TModuleTimeBase(BaseModel):
slut_tid: Optional[datetime] = Field(None, description="Sluttid for live timer")
faktisk_tid_min: Optional[int] = Field(None, ge=0, description="Reel tid i minutter")
fakturerbar_tid_min: Optional[int] = Field(None, ge=0, description="Fakturerbar tid i minutter")
entry_type: Optional[str] = Field("ukendt", pattern="^(opkald|mail|indedesk|manuel|ukendt)$")
entry_type: Optional[str] = Field("ukendt", pattern="^(opkald|mail|anydesk|indedesk|manuel|ukendt)$")
kilde: Optional[str] = Field("manuel", pattern="^(auto|manuel|api)$")
entry_status: Optional[str] = Field("afventer", pattern="^(kladde|afventer|godkendt)$")
medarbejder_id: Optional[int] = Field(None, gt=0)
@ -123,7 +123,7 @@ class TModuleTimeUpdate(BaseModel):
status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$")
entry_status: Optional[str] = Field(None, pattern="^(kladde|afventer|godkendt)$")
fakturerbar_tid_min: Optional[int] = Field(None, ge=0)
entry_type: Optional[str] = Field(None, pattern="^(opkald|mail|indedesk|manuel|ukendt)$")
entry_type: Optional[str] = Field(None, pattern="^(opkald|mail|anydesk|indedesk|manuel|ukendt)$")
class TModuleTimeApproval(BaseModel):

View File

@ -62,6 +62,16 @@ def _resolve_current_user_id(current_user: Optional[dict]) -> Optional[int]:
return None
def _resolve_target_user_id(current_user: Optional[dict], payload_user_id: Any = None) -> Optional[int]:
"""Resolve target medarbejder_id, preferring explicit payload value when provided."""
if payload_user_id not in (None, ""):
try:
return int(payload_user_id)
except (TypeError, ValueError):
return None
return _resolve_current_user_id(current_user)
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
@ -97,6 +107,62 @@ def _legacy_status_from_entry_status(entry_status: str) -> str:
return "pending"
def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try:
candidate_customer_id = int(payload_customer_id) if payload_customer_id is not None else None
except (TypeError, ValueError):
candidate_customer_id = None
# If payload already points to a tmodule customer id, use it directly.
if candidate_customer_id is not None:
direct = execute_query_single(
"SELECT id FROM tmodule_customers WHERE id = %s",
(candidate_customer_id,),
)
if direct and direct.get("id") is not None:
return int(direct["id"])
# Otherwise resolve hub customer id from payload or case.
hub_customer_id = candidate_customer_id
if hub_customer_id is None:
row = execute_query_single(
"""
SELECT customer_id
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(sag_id,),
)
if not row:
return None
customer_id = row.get("customer_id")
try:
hub_customer_id = int(customer_id) if customer_id is not None else None
except (TypeError, ValueError):
hub_customer_id = None
if hub_customer_id is None:
return None
mapped = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
if not mapped:
return None
try:
return int(mapped.get("id")) if mapped.get("id") is not None else None
except (TypeError, ValueError):
return None
# ============================================================================
# SYNC ENDPOINTS
# ============================================================================
@ -1811,23 +1877,24 @@ async def list_time_entries_v1(
):
"""List tidsregistreringer for en sag med filtre til timeline UI."""
try:
clauses = ["sag_id = %s"]
clauses = ["t.sag_id = %s"]
params: List[Any] = [sag_id]
if day:
clauses.append("(worked_date = %s OR DATE(start_tid) = %s)")
clauses.append("(t.worked_date = %s OR DATE(t.start_tid) = %s)")
params.extend([day, day])
if medarbejder_id:
clauses.append("medarbejder_id = %s")
clauses.append("t.medarbejder_id = %s")
params.append(medarbejder_id)
where_sql = " AND ".join(clauses)
query = f"""
SELECT *
FROM tmodule_times
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE {where_sql}
ORDER BY COALESCE(start_tid, worked_date::timestamp, created_at) DESC, id DESC
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC
"""
return execute_query(query, tuple(params))
except Exception as e:
@ -1846,7 +1913,14 @@ async def start_live_timer_v1(
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
bruger_id = _resolve_current_user_id(current_user) or payload.get("medarbejder_id")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
if customer_id is None:
raise HTTPException(
status_code=400,
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
)
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
if not bruger_id:
raise HTTPException(status_code=400, detail="medarbejder_id could not be resolved")
@ -1874,7 +1948,7 @@ async def start_live_timer_v1(
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE 0 END,
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
entry_status = 'afventer',
status = 'pending'
@ -1913,7 +1987,7 @@ async def start_live_timer_v1(
""",
(
sag_id,
payload.get("customer_id"),
customer_id,
payload.get("beskrivelse") or payload.get("description"),
0.01,
now.date(),
@ -1954,7 +2028,7 @@ async def stop_live_timer_v1(
try:
now = datetime.now()
time_id = payload.get("time_id")
bruger_id = _resolve_current_user_id(current_user) or payload.get("medarbejder_id")
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
if time_id:
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
@ -1995,7 +2069,7 @@ async def stop_live_timer_v1(
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE 0 END,
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
worked_date = COALESCE(worked_date, %s),
entry_status = %s,
@ -2037,7 +2111,14 @@ async def create_manual_time_v1(
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
bruger_id = _resolve_current_user_id(current_user) or payload.get("medarbejder_id")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
if customer_id is None:
raise HTTPException(
status_code=400,
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
)
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
default_user_name = (
(current_user or {}).get("username")
or (current_user or {}).get("full_name")
@ -2099,7 +2180,7 @@ async def create_manual_time_v1(
(
sag_id,
payload.get("solution_id"),
payload.get("customer_id"),
customer_id,
payload.get("beskrivelse") or payload.get("description"),
max(actual_minutes / 60.0, 0.01),
worked_date,
@ -2118,7 +2199,7 @@ async def create_manual_time_v1(
False,
round_block_min,
not_placed,
(billable_minutes / 60.0) if billable else 0,
(billable_minutes / 60.0) if billable else None,
(round_block_min / 60.0) if billable else None,
)
)
@ -2201,15 +2282,18 @@ async def approve_time_entry_v1(
payload: Dict[str, Any] = Body(default={}),
current_user: Optional[dict] = Depends(get_optional_user)
):
"""Approve time entry for billing (kræver type)."""
"""Approve time entry for billing (kræver type, med admin-override for ukendt)."""
try:
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if entry_type == "ukendt":
raise HTTPException(status_code=400, detail="entry_type is required before approval")
if not is_admin_approver:
raise HTTPException(status_code=400, detail="entry_type is required before approval")
logger.warning("⚠️ Admin approved time entry with ukendt type (time_id=%s)", time_id)
billable = bool(payload.get("fakturerbar", entry.get("billable", True)))
billed_minutes = payload.get("fakturerbar_tid_min")

22
apply_migration_150.py Normal file
View File

@ -0,0 +1,22 @@
import psycopg2
from app.core.config import settings
def apply_migration():
conn = psycopg2.connect(settings.DATABASE_URL)
conn.autocommit = True
cur = conn.cursor()
try:
with open('migrations/150_sag_tidsforbrug_v1.sql', 'r') as f:
sql = f.read()
cur.execute(sql)
print("Migration 150 applied successfully.")
except Exception as e:
print(f"Error applying migration 150: {e}")
finally:
cur.close()
conn.close()
if __name__ == "__main__":
apply_migration()

1
final_wc.txt Normal file
View File

@ -0,0 +1 @@
11989 app/modules/sag/templates/detail.html

18
fix_domcontent.py Normal file
View File

@ -0,0 +1,18 @@
import re
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
# Fix the duplicate function
text = re.sub(r'( function getTimeV1EmployeeId\(\) \{\n const val = document.getElementById\(\'timeV1EmployeeId\'\)\?\.value;\n return val \? Number\(val\) : null;\n \}\n\n)+', r'\1', text)
# Fix the undefined updateTimeTotal issue inside DOMContentLoaded
# The lines to remove are:
# if(hInput) hInput.addEventListener('input', updateTimeTotal);
# if(mInput) mInput.addEventListener('input', updateTimeTotal);
text = re.sub(r"if\(hInput\)\s*hInput\.addEventListener\('input',\s*updateTimeTotal\);\s*\n\s*if\(mInput\)\s*mInput\.addEventListener\('input',\s*updateTimeTotal\);", "", text)
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("done")

View File

@ -0,0 +1,19 @@
import re
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
# Define getTimeV1EmployeeId
func_def = """ function getTimeV1EmployeeId() {
const val = document.getElementById('timeV1EmployeeId')?.value;
return val ? Number(val) : null;
}
async function createManualTimeV1(event) {"""
text = text.replace(" async function createManualTimeV1(event) {", func_def)
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("function defined")

0
fix_js2.py Normal file
View File

13
fix_tab.py Normal file
View File

@ -0,0 +1,13 @@
import re
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
content = f.read()
# Make the timetracking tab visible by adding it to standardModuleSet, or just removing the 'data-module="timetracking"'
# Actually, the easiest way is to remove data-module="timetracking" and data-module-tab="timetracking"
# Wait, if we remove it, the tab won't be hidden, which is good.
content = content.replace('data-module-tab="timetracking"', '')
content = content.replace('data-module="timetracking"', '')
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(content)

22
fix_time_modal.py Normal file
View File

@ -0,0 +1,22 @@
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
bad_str = """ } timer`;
}
}
// Add listeners safely
document.addEventListener('DOMContentLoaded', () => {
bindTimeModalCalculations(); const solAddTime = document.getElementById('sol_add_time');"""
good_str = """
// Add listeners safely
document.addEventListener('DOMContentLoaded', () => {
bindTimeModalCalculations();
const solAddTime = document.getElementById('sol_add_time');"""
text = text.replace(bad_str, good_str)
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("Fixed stray timer characters.")

223
fix_timeline_clean.py Normal file
View File

@ -0,0 +1,223 @@
import re
with open("app/modules/sag/templates/detail.html", "r", encoding="utf-8") as f:
text = f.read()
old_css_pattern = r"\.time-v1-track \{.*?\n \}"
new_css = """
.time-v1-global-timeline {
position: relative;
padding-left: 2rem;
margin-bottom: 2rem;
}
.time-v1-global-timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0.75rem;
width: 2px;
background-color: var(--accent, #0f4c75);
opacity: 0.2;
}
.time-v1-date-node {
position: relative;
margin-bottom: 1.5rem;
}
.time-v1-date-badge {
display: inline-block;
background-color: var(--accent, #0f4c75);
color: #fff;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1rem;
margin-left: -2.5rem;
position: relative;
z-index: 1;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.time-v1-item {
position: relative;
background: #fff;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.05);
transition: all 0.2s ease;
}
.time-v1-item::before {
content: '';
position: absolute;
top: 1.5rem;
left: -2rem;
width: 1rem;
height: 2px;
background-color: var(--accent, #0f4c75);
opacity: 0.2;
}
.time-v1-item:hover {
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.time-v1-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background-color: color-mix(in srgb, var(--accent, #0f4c75) 10%, white);
color: var(--accent, #0f4c75);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
flex-shrink: 0;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
"""
new_js = """function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeV1Timeline');
if (!timeline) return;
if (!entries || entries.length === 0) {
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
return;
}
// Saml og sortér alle tidsregistreringer efter dato, nyeste først
const sortedEntries = [...entries].sort((a, b) => {
const dateA = new Date(a.worked_date || a.start_tid || 0);
const dateB = new Date(b.worked_date || b.start_tid || 0);
return dateB - dateA;
});
// Gruppér efter formatert dato
const groupedByDate = {};
sortedEntries.forEach((entry) => {
const rawDate = new Date(entry.worked_date || entry.start_tid || 0);
const dateKey = !isNaN(rawDate.getTime())
? rawDate.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: 'Ukendt dato';
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
groupedByDate[dateKey].push(entry);
});
// Byg HTML for den overordnede tidslinje
let html = '<div class="time-v1-global-timeline">';
Object.entries(groupedByDate).forEach(([dateLabel, dateEntries]) => {
// Konverter det første bogstav i dato-strengen til stort
const formattedDateLab = dateLabel.charAt(0).toUpperCase() + dateLabel.slice(1);
html += `
<div class="time-v1-date-node">
<div class="time-v1-date-badge">
<i class="bi bi-calendar3 me-1"></i>${formattedDateLab}
</div>
`;
dateEntries.forEach(entry => {
const desc = escapeHtml(entry.beskrivelse || 'Ingen beskrivelse');
const userName = escapeHtml(entry.bruger_navn || 'Ukendt');
// Lav initialer til Avatar
const initials = userName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?';
// Formatér tid
let timeOutput = '0 t';
let isRunning = false;
let clockClass = "text-muted";
if (entry.kilde === 'live' && !entry.faktisk_tid_min && !entry.stop_tid) {
timeOutput = 'Kører...';
isRunning = true;
clockClass = "text-success fw-bold";
} else if (entry.is_running) {
timeOutput = 'Kører...';
isRunning = true;
clockClass = "text-success fw-bold";
} else if (entry.faktisk_tid_min !== null && entry.faktisk_tid_min !== undefined) {
const h = Math.floor(entry.faktisk_tid_min / 60);
const m = Math.floor(entry.faktisk_tid_min % 60);
timeOutput = `${h}t ${m}m`;
} else {
// Reservere for original_hours fallback
const origHours = parseFloat(entry.original_hours || 0);
const h = Math.floor(origHours);
const m = Math.round((origHours - h) * 60);
timeOutput = `${h}t ${m}m`;
}
// Tjek synlighed for kunden (intern markering)
const isInternal = entry.is_internal ? true : false;
const internalBadge = isInternal
? `<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle rounded-pill me-2" title="Skjult for kunde">
<i class="bi bi-eye-slash-fill me-1"></i>Intern
</span>`
: '';
html += `
<div class="time-v1-item d-flex gap-3 align-items-start">
<div class="time-v1-avatar" title="${userName}">
${initials}
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold text-dark">${userName}</div>
<div class="small text-muted mb-2">
<i class="bi bi-clock ${clockClass} me-1"></i>
<span class="${isRunning ? 'text-success fw-bold' : ''}">${timeOutput}</span>
${entry.entry_type ? ` &middot; <span class="badge bg-light text-secondary border">${escapeHtml(entry.entry_type)}</span>` : ''}
</div>
</div>
<div class="d-flex align-items-center">
${internalBadge}
<button class="btn btn-sm btn-link text-muted p-0" onclick="deleteTimeV1Entry(${entry.id})" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="text-dark bg-light rounded p-2 small border" style="white-space: pre-wrap;">${desc}</div>
</div>
</div>
`;
});
html += `</div>`; // Luk time-v1-date-node
});
html += '</div>'; // Luk time-v1-global-timeline
timeline.innerHTML = html;
}"""
old_js_pattern = r'function renderTimeV1Timeline\(entries\).*?\n }'
orig_text_len = len(text)
import sys
if re.search(old_css_pattern, text, re.DOTALL):
text = re.sub(old_css_pattern, new_css.strip(), text, flags=re.DOTALL)
else:
print("Could NOT find old CSS!")
if re.search(old_js_pattern, text, re.DOTALL):
text = re.sub(old_js_pattern, new_js.strip(), text, flags=re.DOTALL)
else:
print("Could NOT find old JS!")
with open("app/modules/sag/templates/detail.html", "w", encoding="utf-8") as f:
f.write(text)
print(f"Replacement complete! Original length {orig_text_len}, new length {len(text)}")

195
fix_timeline_colors.py Normal file
View File

@ -0,0 +1,195 @@
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
start_marker = " function renderTimeV1Timeline(entries) {"
end_marker = " async function loadTimeTrackingTab() {"
start_idx = text.index(start_marker)
end_idx = text.index(end_marker)
print(f"Replacing lines {text[:start_idx].count(chr(10))+1} to {text[:end_idx].count(chr(10))+1}")
new_func = r""" function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
if (!timeline) return;
if (!entries || entries.length === 0) {
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
return;
}
const START_HOUR = 7;
const TOTAL_HOURS = 10;
const HOUR_HEIGHT = 60;
const PALETTE = [
{ border: '#0f4c75', bg: 'rgba(15,76,117,0.09)', header: 'rgba(15,76,117,0.08)' },
{ border: '#ef4444', bg: 'rgba(239,68,68,0.09)', header: 'rgba(239,68,68,0.08)' },
{ border: '#10b981', bg: 'rgba(16,185,129,0.09)', header: 'rgba(16,185,129,0.08)' },
{ border: '#f59e0b', bg: 'rgba(245,158,11,0.09)', header: 'rgba(245,158,11,0.08)' },
{ border: '#8b5cf6', bg: 'rgba(139,92,246,0.09)', header: 'rgba(139,92,246,0.08)' },
{ border: '#ec4899', bg: 'rgba(236,72,153,0.09)', header: 'rgba(236,72,153,0.08)' },
{ border: '#06b6d4', bg: 'rgba(6,182,212,0.09)', header: 'rgba(6,182,212,0.08)' },
{ border: '#f97316', bg: 'rgba(249,115,22,0.09)', header: 'rgba(249,115,22,0.08)' },
];
const allUsers = [...new Set(entries.map(e => e.bruger_navn || e.user_name || 'Ukendt'))].sort();
const userColor = {};
allUsers.forEach((u, i) => { userColor[u] = PALETTE[i % PALETTE.length]; });
const groupedByDate = {};
entries.forEach(entry => {
let dateKey;
if (entry.start_tid) dateKey = entry.start_tid.substring(0, 10);
else if (entry.worked_date) dateKey = entry.worked_date.substring(0, 10);
else if (entry.created_at) dateKey = entry.created_at.substring(0, 10);
else dateKey = 'Ukendt dato';
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
groupedByDate[dateKey].push(entry);
});
const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a));
let html = '';
sortedDates.forEach(dateStr => {
const dayEntries = groupedByDate[dateStr];
let dateLab = dateStr;
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
dateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
dateLab = dateLab.charAt(0).toUpperCase() + dateLab.slice(1);
}
} catch(e) {}
const techPlaced = {};
const unplaced = [];
const userTotals = {};
dayEntries.forEach(entry => {
const tech = entry.bruger_navn || entry.user_name || 'Ukendt';
const mins = entry.faktisk_tid_min
? parseInt(entry.faktisk_tid_min)
: Math.round(parseFloat(entry.original_hours || entry.timer || 0) * 60);
userTotals[tech] = (userTotals[tech] || 0) + mins;
if (entry.start_tid) {
if (!techPlaced[tech]) techPlaced[tech] = [];
techPlaced[tech].push(entry);
} else {
unplaced.push(entry);
}
});
const techNames = Object.keys(techPlaced).sort();
html += `<div class="time-v1-calendar-container">
<div class="time-v1-calendar-header">
<i class="bi bi-calendar3 text-primary"></i> ${dateLab}
</div>
<div class="time-v1-calendar-grid">
<div class="time-v1-time-axis">`;
for (let i = 0; i <= TOTAL_HOURS; i++) {
const h = START_HOUR + i;
html += `<div class="time-v1-hour-marker" style="top:${i * HOUR_HEIGHT}px">${String(h).padStart(2,'0')}:00</div>`;
}
html += `</div>`;
techNames.forEach(tech => {
const c = userColor[tech] || PALETTE[0];
const tot = userTotals[tech] || 0;
const totS = tot >= 60
? `${Math.floor(tot/60)}t${tot%60 ? ' '+tot%60+'m' : ''}`
: `${tot}m`;
html += `<div class="time-v1-tech-col" style="border-top:3px solid ${c.border};">
<div class="time-v1-tech-header" style="background:${c.header};">
<i class="bi bi-person-fill" style="color:${c.border};"></i>
<span style="color:${c.border};font-weight:600;">${escapeHtml(tech)}</span>
<span class="ms-auto badge" style="background:${c.border};color:#fff;font-size:0.7rem;">${totS}</span>
</div>
<div class="time-v1-tech-body">`;
const posEntries = [];
techPlaced[tech].forEach(entry => {
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
const startObj = new Date(entry.start_tid);
let durMin = 30;
if (entry.faktisk_tid_min) durMin = parseInt(entry.faktisk_tid_min);
else if (entry.original_hours || entry.timer) durMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
let sH = startObj.getHours(), sM = startObj.getMinutes();
if (sH < START_HOUR) { durMin -= (START_HOUR * 60 - sH * 60 - sM); sH = START_HOUR; sM = 0; }
let topPx = ((sH - START_HOUR) + sM / 60) * HOUR_HEIGHT;
let heightPx = (durMin / 60) * HOUR_HEIGHT;
if (topPx < 0) topPx = 0;
if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) heightPx = TOTAL_HOURS * HOUR_HEIGHT - topPx;
if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
const endObj = new Date(startObj.getTime() + durMin * 60000);
const timeStr = `${String(startObj.getHours()).padStart(2,'0')}:${String(startObj.getMinutes()).padStart(2,'0')} \u2013 ${String(endObj.getHours()).padStart(2,'0')}:${String(endObj.getMinutes()).padStart(2,'0')}`;
posEntries.push({ topPx, heightPx, desc, timeStr, startMin: topPx, endMin: topPx + heightPx });
}
});
posEntries.sort((a, b) => a.startMin - b.startMin);
const lanes = [];
posEntries.forEach(e => {
let placed = false;
for (let li = 0; li < lanes.length; li++) {
if (lanes[li] <= e.startMin) { e.lane = li; lanes[li] = e.endMin; placed = true; break; }
}
if (!placed) { e.lane = lanes.length; lanes.push(e.endMin); }
});
const numLanes = lanes.length || 1;
posEntries.forEach(e => {
e.laneSpan = 1;
for (let li = e.lane + 1; li < numLanes; li++) {
if (!posEntries.some(o => o !== e && o.lane === li && o.startMin < e.endMin && o.endMin > e.startMin)) e.laneSpan++;
else break;
}
const lW = 100 / numLanes;
html += `<div class="time-v1-entry-block"
style="top:${e.topPx}px;height:${e.heightPx}px;left:${(e.lane*lW).toFixed(1)}%;width:calc(${(e.laneSpan*lW).toFixed(1)}% - 3px);border-left-color:${c.border};background:${c.bg};"
title="${e.desc}">
<div class="time-v1-entry-time">${e.timeStr}</div>
<div class="time-v1-entry-desc">${e.desc}</div>
</div>`;
});
html += `</div></div>`;
});
html += `</div>`;
if (unplaced.length > 0) {
html += `<div class="time-v1-unplaced-container">
<span class="text-muted small fw-semibold me-2"><i class="bi bi-clock-history"></i> Uden tidsrum:</span>`;
unplaced.forEach(u => {
const tech = u.bruger_navn || u.user_name || 'Ukendt';
const c = userColor[tech] || PALETTE[0];
const mins = u.faktisk_tid_min ? parseInt(u.faktisk_tid_min) : Math.round(parseFloat(u.original_hours || u.timer || 0) * 60);
const hStr = mins >= 60 ? `${Math.floor(mins/60)}t${mins%60?' '+mins%60+'m':''}` : `${mins}m`;
const desc = escapeHtml(u.beskrivelse || u.description || '');
html += `<div class="time-v1-unplaced-item" style="border-color:${c.border};color:${c.border};">
<i class="bi bi-person-fill"></i> ${escapeHtml(tech)} &bull; ${hStr}${desc ? ' &middot; <em style="opacity:.7;font-size:.72rem">'+desc+'</em>' : ''}
</div>`;
});
html += `</div>`;
}
html += `</div>`;
});
timeline.innerHTML = html;
}
"""
text = text[:start_idx] + new_func + text[end_idx:]
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("Done - renderTimeV1Timeline replaced with user-color version")

9
get_js.py Normal file
View File

@ -0,0 +1,9 @@
import re
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
m = re.search(r'function renderTimeV1Timeline\(entries\)\s*{.*?timeline\.innerHTML = Object\.entries\(grouped\).*?\}', text, re.DOTALL)
if m:
with open('old_js.txt', 'w') as out:
out.write(m.group(0))
print("Wrote js")

6
get_saveTime.py Normal file
View File

@ -0,0 +1,6 @@
with open("app/modules/sag/templates/detail.html") as f:
text = f.read()
s = text.find("async function saveTime()")
e = text.find("}", text.find("fetch", s)) + 200
print(text[s:e])

23
main.py
View File

@ -109,6 +109,7 @@ from app.auth.backend import admin as auth_admin_api
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
from app.routers import anydesk
from app.anydesk.backend import views as anydesk_views
# Modules
from app.modules.webshop.backend import router as webshop_api
@ -210,6 +211,19 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED:
from app.modules.links.jobs.dead_link_check import check_links_health
backup_scheduler.scheduler.add_job(
func=check_links_health,
trigger=IntervalTrigger(minutes=settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES),
id='check_links_health',
name='Check Links Health',
max_instances=1,
replace_existing=True
)
logger.info("✅ Links health job scheduled (every %d minutes)", settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES)
logger.info("✅ System initialized successfully")
yield
# Shutdown
@ -414,6 +428,10 @@ app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
app.include_router(links_api.router, prefix="/api/v1", tags=["Links"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"])
@ -441,6 +459,11 @@ app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"])
app.include_router(orders_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views
app.include_router(links_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,7 @@
-- Migration 152: Add anydesk_id to users table
-- Allows technicians to register their own AnyDesk client ID in their profile
ALTER TABLE users
ADD COLUMN IF NOT EXISTS anydesk_id VARCHAR(50),
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS title VARCHAR(100);

View File

@ -0,0 +1,21 @@
-- Migration 153: Multiple AnyDesk IDs per technician
-- Replaces the single anydesk_id column on users with a dedicated table
CREATE TABLE IF NOT EXISTS user_anydesk_ids (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
anydesk_id VARCHAR(50) NOT NULL,
label VARCHAR(100), -- optional label, e.g. "Privat laptop", "Kontor-PC"
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (user_id, anydesk_id)
);
CREATE INDEX IF NOT EXISTS idx_user_anydesk_ids_user ON user_anydesk_ids(user_id);
CREATE INDEX IF NOT EXISTS idx_user_anydesk_ids_anydesk_id ON user_anydesk_ids(anydesk_id);
-- Migrate existing single anydesk_id values from users table
INSERT INTO user_anydesk_ids (user_id, anydesk_id, label)
SELECT user_id, anydesk_id, 'Primær'
FROM users
WHERE anydesk_id IS NOT NULL AND anydesk_id != ''
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,119 @@
-- Migration 154: Links / Endpoints module foundation
-- Removable module schema for operational access layer
CREATE TABLE IF NOT EXISTS link_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
icon VARCHAR(100),
sort_order INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS links (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
type VARCHAR(20) NOT NULL,
url TEXT,
host TEXT,
port INTEGER,
username TEXT,
icon VARCHAR(100),
color VARCHAR(32),
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
hardware_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
vault_item_id TEXT,
vault_item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
is_critical BOOLEAN NOT NULL DEFAULT FALSE,
is_favorite BOOLEAN NOT NULL DEFAULT FALSE,
environment VARCHAR(20) NOT NULL DEFAULT 'prod',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP,
CONSTRAINT links_type_check CHECK (type IN ('http', 'ssh', 'rdp', 'command')),
CONSTRAINT links_environment_check CHECK (environment IN ('prod', 'test', 'dev')),
CONSTRAINT links_port_check CHECK (port IS NULL OR (port >= 1 AND port <= 65535))
);
CREATE TABLE IF NOT EXISTS link_category_map (
link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES link_categories(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (link_id, category_id)
);
CREATE TABLE IF NOT EXISTS link_runbooks (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS link_runbook_steps (
id SERIAL PRIMARY KEY,
runbook_id INTEGER NOT NULL REFERENCES link_runbooks(id) ON DELETE CASCADE,
step_order INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
link_id INTEGER REFERENCES links(id) ON DELETE SET NULL,
command_text TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT link_runbook_steps_unique_order UNIQUE (runbook_id, step_order)
);
CREATE TABLE IF NOT EXISTS link_status_checks (
id SERIAL PRIMARY KEY,
link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'unknown',
details JSONB NOT NULL DEFAULT '{}'::jsonb,
checked_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT link_status_checks_status_check CHECK (status IN ('ok', 'down', 'unknown'))
);
CREATE TABLE IF NOT EXISTS link_access_log (
id SERIAL PRIMARY KEY,
link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
action_type VARCHAR(50) NOT NULL,
case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS links_audit_log (
id SERIAL PRIMARY KEY,
link_id INTEGER REFERENCES links(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL,
actor_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_links_scope_case ON links(case_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_links_scope_customer ON links(customer_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_links_scope_hardware ON links(hardware_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_links_name ON links(name);
CREATE INDEX IF NOT EXISTS idx_links_host ON links(host);
CREATE INDEX IF NOT EXISTS idx_links_url ON links(url);
CREATE INDEX IF NOT EXISTS idx_links_type ON links(type);
CREATE INDEX IF NOT EXISTS idx_links_updated_at ON links(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_link_status_checks_link_checked ON link_status_checks(link_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_link_access_log_created ON link_access_log(created_at DESC);
INSERT INTO link_categories (name, icon, sort_order)
VALUES
('Network', 'bi-diagram-3', 10),
('Monitoring', 'bi-activity', 20),
('Servers', 'bi-hdd-network', 30),
('Operations', 'bi-tools', 40),
('Runbooks', 'bi-journal-check', 50)
ON CONFLICT (name) DO NOTHING;

View File

@ -0,0 +1,42 @@
-- Migration 155: Links module permissions
INSERT INTO permissions (code, description, category) VALUES
('links.read', 'View links and endpoint actions', 'links'),
('links.create', 'Create links', 'links'),
('links.update', 'Update links', 'links'),
('links.delete', 'Delete links', 'links'),
('links.use', 'Use links and quick actions', 'links'),
('links.diagnose', 'Run multi-open diagnose actions', 'links')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Administrators'
AND p.category = 'links'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Managers'
AND p.code IN ('links.read', 'links.create', 'links.update', 'links.use', 'links.diagnose')
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Technicians'
AND p.code IN ('links.read', 'links.use', 'links.diagnose')
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Viewers'
AND p.code = 'links.read'
ON CONFLICT DO NOTHING;

54
old_js.txt Normal file
View File

@ -0,0 +1,54 @@
function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
const unplaced = document.getElementById('timeUnplacedEntries');
const activeBanner = document.getElementById('timeActiveBanner');
const activeBannerText = document.getElementById('timeActiveBannerText');
if (!timeline || !unplaced) return;
const active = (entries || []).find((entry) => entry.aktiv_timer && !entry.slut_tid);
if (active) {
activeBanner.classList.remove('d-none');
activeBannerText.textContent = `Aktiv på ${active.user_name || 'ukendt bruger'}: ${active.description || 'uden beskrivelse'}`;
} else {
activeBanner.classList.add('d-none');
}
const unplacedEntries = (entries || []).filter((entry) => entry.ikke_placeret || (!entry.start_tid && !entry.slut_tid));
if (!unplacedEntries.length) {
unplaced.innerHTML = '<div class="text-muted small">Ingen entries uden tidspunkter.</div>';
} else {
unplaced.innerHTML = unplacedEntries.map((entry) => {
return `
<div class="border rounded p-2 mb-2">
<div class="small fw-semibold">${entry.description || 'Uden beskrivelse'}</div>
<div class="small text-muted">${minutesToLabel(entry.faktisk_tid_min || Math.round((entry.original_hours || 0) * 60))}</div>
<div class="mt-1">${timeStatusBadge(entry.entry_status || 'afventer')}</div>
</div>
`;
}).join('');
}
if (!entries || !entries.length) {
timeline.innerHTML = '<div class="text-muted text-center py-3">Ingen tidsregistreringer endnu.</div>';
return;
}
const grouped = {};
(entries || []).forEach((entry) => {
const key = `${entry.medarbejder_id || 0}:${entry.user_name || 'Ukendt bruger'}`;
if (!grouped[key]) grouped[key] = [];
grouped[key].push(entry);
});
if (!entries || !entries.length) {
timeline.innerHTML = '<div class="text-muted text-center py-3">Ingen tidsregistreringer endnu.</div>';
return;
}
timeline.innerHTML = Object.entries(grouped).map(([key, rows]) => {
const userName = key.split(':')[1] || 'Ukendt bruger';
const sortedRows = [...rows].sort((a, b) => {
const aDate = new Date(a.start_tid || a.slut_tid || a.worked_date || a.created_at || 0).getTime();
const bDate = new Date(b.start_tid || b.slut_tid || b.worked_date || b.created_at || 0).getTime();
return bDate - aDate;
}

View File

@ -1,24 +1,17 @@
import re
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
content = f.read()
with open('app/modules/sag/templates/detail.html', 'r') as f:
text = f.read()
def extract_between(text, start_marker, end_marker):
start = text.find(start_marker)
if start == -1: return "", text
end = text.find(end_marker, start)
if end == -1: return "", text
match = text[start:end+len(end_marker)]
text = text[:start] + text[end+len(end_marker):]
return match, text
# Let's verify the file content is around 6600 lines
print(f"Total lines: {len(text.splitlines())}")
def extract_div_by_marker(text, marker):
start = text.find(marker)
if start == -1: return "", text
# find the open div tag nearest to the marker looking backwards
div_start = text.rfind('<div', 0, start)
# wait, sometimes marker is inside the div or before the div.
pass
print("Content loaded, len:", len(content))
# Search for the function renderTimeV1Timeline
match = re.search(r'function renderTimeV1Timeline\(entries\)\s*{.*?timeline\.innerHTML = Object\.entries\(grouped\).*?\}', text, re.DOTALL)
if match:
print(f"Found render function, length: {len(match.group(0))}")
else:
print(f"Could not find render function")
match = re.search(r'function renderTimeV1Timeline\(entries\)\s*{', text)
if match:
print("Found definition at index:", match.start())

View File

@ -1,66 +1,22 @@
import sys
import re
def replace_chunk(text, start_str, end_str, new_content):
start = text.find(start_str)
end = text.find(end_str, start)
if start != -1 and end != -1:
end += len(end_str)
return text[:start] + new_content + text[end:]
return text
def get_balanced_div(html, start_idx):
i = start_idx
tag_count = 0
while i < len(html):
# We need to correctly parse `<div` vs `</div>` handling any attributes
# Find next tag start
next_open = html.find('<div', i)
next_close = html.find('</div>', i)
with open('app/modules/sag/templates/detail.html', 'r') as f:
text = f.read()
if next_open == -1 and next_close == -1:
break
# Test finding CSS
start_css = ".time-v1-track {"
end_css = " .time-v1-metric {\n font-size: 0.8rem;\n margin-top: 0.18rem;\n }"
if start_css in text and end_css in text:
print("Found CSS block")
if next_open != -1 and (next_open < next_close or next_close == -1):
tag_count += 1
i = next_open + 4
else:
tag_count -= 1
i = next_close + 6
if tag_count == 0:
return start_idx, i
return start_idx, -1
html = open('app/modules/sag/templates/detail.html').read()
def extract_widget(html, data_module_name):
pattern = f'<div[^>]*data-module="{data_module_name}"[^>]*>'
match = re.search(pattern, html)
if not match: return "", html
start, end = get_balanced_div(html, match.start())
widget = html[start:end]
html = html[:start] + html[end:]
return widget, html
# Let's extract assignment card
# It does not have data-module, but we know it follows: `<!-- Assignment Card -->`
def extract_by_comment(html, comment_str):
c_start = html.find(comment_str)
if c_start == -1: return "", html
div_start = html.find('<div', c_start)
if div_start == -1: return "", html
start, end = get_balanced_div(html, div_start)
widget = html[c_start:end] # include the comment
html = html[:c_start] + html[end:]
return widget, html
def extract_block_by_id(html, id_name):
pattern = f'<div[^>]*id="{id_name}"[^>]*>'
match = re.search(pattern, html)
if not match: return "", html
start, end = get_balanced_div(html, match.start())
widget = html[start:end]
html = html[:start] + html[end:]
return widget, html
# Test extractions
ass, _ = extract_by_comment(html, '<!-- Assignment Card -->')
print(f"Assignment widget len: {len(ass)}")
cust, _ = extract_widget(html, "customers")
print(f"Customer widget len: {len(cust)}")
rem, _ = extract_widget(html, "reminders")
print(f"Reminders widget len: {len(rem)}")
# Test finding JS
start_js = " function renderTimeV1Timeline(entries) {"
end_js = " </div>\n `;\n }).join('');\n }"
if start_js in text and end_js in text:
print("Found JS block")

323
patch_detail.py Normal file
View File

@ -0,0 +1,323 @@
import re
import sys
def patch():
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
css_start = text.find('.time-v1-user-section {')
css_end = text.find('</style>', css_start)
css_new = """
.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; /* 10h * 60Px = 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);
}
"""
if css_start != -1 and css_end != -1:
text = text[:css_start] + css_new + text[css_end:]
print("Replaced CSS.")
js_start = text.find('function renderTimeV1Timeline(entries) {')
js_end = text.find('async function loadTimeTrackingTab() {', js_start)
js_new = """function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
if (!timeline) return;
if (!entries || entries.length === 0) {
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
return;
}
const START_HOUR = 7;
const TOTAL_HOURS = 10; // 07:00 to 17:00
const HOUR_HEIGHT = 60; // px
const groupedByDate = {};
entries.forEach((entry) => {
let dateKey = 'Ukendt dato';
if (entry.start_tid) {
dateKey = entry.start_tid.split('T')[0];
} else if (entry.worked_date) {
dateKey = entry.worked_date;
} else if (entry.created_at) {
dateKey = entry.created_at.split('T')[0];
}
// Keep only first 10 chars for proper grouping if it's an ISO timestamp
if (dateKey.length > 10) dateKey = dateKey.substring(0, 10);
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
groupedByDate[dateKey].push(entry);
});
const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a));
let html = '';
sortedDates.forEach(dateStr => {
const dayEntries = groupedByDate[dateStr];
let formattedDateLab = dateStr;
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1);
}
} catch(e){}
const techs = {};
const unplaced = [];
dayEntries.forEach(entry => {
const tech = entry.bruger_navn || entry.user_name || 'Ukendt';
if (!techs[tech]) techs[tech] = [];
if (!entry.start_tid || entry.start_tid === null) {
unplaced.push(entry);
} else {
techs[tech].push(entry);
}
});
const techNames = Object.keys(techs).sort();
html += `
<div class="time-v1-calendar-container">
<div class="time-v1-calendar-header">
<i class="bi bi-calendar3 text-primary"></i> ${formattedDateLab}
</div>
<div class="time-v1-calendar-grid">
<div class="time-v1-time-axis">
`;
for (let i = 0; i <= TOTAL_HOURS; i++) {
const h = START_HOUR + i;
const top = i * HOUR_HEIGHT;
html += `<div class="time-v1-hour-marker" style="top: ${top}px">${h.toString().padStart(2, '0')}:00</div>`;
}
html += `</div>`;
techNames.forEach(tech => {
html += `
<div class="time-v1-tech-col" data-tech="${escapeHtml(tech)}" data-date="${dateStr}">
<div class="time-v1-tech-header">
<i class="bi bi-person-circle text-secondary"></i> ${escapeHtml(tech)}
</div>
<div class="time-v1-tech-body">
`;
techs[tech].forEach(entry => {
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
const status = entry.entry_status || entry.status || 'kladde';
let cssClass = 'time-v1-entry-kladde';
if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
const startObj = new Date(entry.start_tid);
let durationMin = 30; // default length
if (entry.faktisk_tid_min) {
durationMin = parseInt(entry.faktisk_tid_min);
} else if (entry.original_hours || entry.timer) {
durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
}
let startH = startObj.getHours();
let startM = startObj.getMinutes();
if (startH < START_HOUR) {
durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
startH = START_HOUR;
startM = 0;
}
let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
let heightPx = (durationMin / 60) * HOUR_HEIGHT;
if (topPx < 0) topPx = 0;
if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
}
if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
const endObj = new Date(startObj.getTime() + durationMin * 60000);
const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
html += `
<div class="time-v1-entry-block ${cssClass}" style="top: ${topPx}px; height: ${heightPx}px;" title="${desc}">
<div class="time-v1-entry-time">${timeStr}</div>
<div class="time-v1-entry-desc text-wrap">${desc}</div>
</div>
`;
}
});
html += `
</div>
</div>
`;
});
html += `</div>`;
if (unplaced.length > 0) {
html += `<div class="time-v1-unplaced-container">
<span class="text-muted small fw-semibold"><i class="bi bi-clock-history"></i> Uden tidsrum:</span>
`;
unplaced.forEach(u => {
const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
const hrs = u.original_hours || u.timer || 0;
html += `<div class="time-v1-unplaced-item">
<i class="bi bi-person text-secondary"></i> ${userName} &bull; ${hrs}t
</div>`;
});
html += `</div>`;
}
html += `</div>`;
});
timeline.innerHTML = html;
}
"""
if js_start != -1 and js_end != -1:
text = text[:js_start] + js_new + text[js_end:]
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("Replaced JS and saved detail.html.")
else:
print("JS function not found or end not found.")
patch()

741
patch_everything.py Normal file
View File

@ -0,0 +1,741 @@
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
# 1. Timeline Layout & CSS
css_start = text.find('.time-v1-global-timeline {')
if css_start == -1:
css_start = text.find('.time-v1-calendar-container {')
if css_start != -1:
css_end = text.find('</style>', css_start)
css_new = """
.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; /* 10h * 60Px = 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);
}
"""
if css_end != -1:
text = text[:css_start] + css_new + text[css_end:]
print("CSS applied.")
js_start = text.find('function renderTimeV1Timeline(entries) {')
js_end = text.find('async function loadTimeTrackingTab() {', js_start)
js_new = """function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
if (!timeline) return;
if (!entries || entries.length === 0) {
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
return;
}
const START_HOUR = 7;
const TOTAL_HOURS = 10; // 07:00 to 17:00
const HOUR_HEIGHT = 60; // px
const groupedByDate = {};
entries.forEach((entry) => {
let dateKey = 'Ukendt dato';
if (entry.start_tid) {
dateKey = entry.start_tid.split('T')[0];
} else if (entry.worked_date) {
dateKey = entry.worked_date;
} else if (entry.created_at) {
dateKey = entry.created_at.split('T')[0];
}
// Keep only first 10 chars for proper grouping if it's an ISO timestamp
if (dateKey.length > 10) dateKey = dateKey.substring(0, 10);
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
groupedByDate[dateKey].push(entry);
});
const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a));
let html = '';
sortedDates.forEach(dateStr => {
const dayEntries = groupedByDate[dateStr];
let formattedDateLab = dateStr;
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1);
}
} catch(e){}
const techs = {};
const unplaced = [];
dayEntries.forEach(entry => {
const tech = entry.bruger_navn || entry.user_name || 'Ukendt';
if (!techs[tech]) techs[tech] = [];
if (!entry.start_tid || entry.start_tid === null) {
unplaced.push(entry);
} else {
techs[tech].push(entry);
}
});
const techNames = Object.keys(techs).sort();
html += `
<div class="time-v1-calendar-container">
<div class="time-v1-calendar-header">
<i class="bi bi-calendar3 text-primary"></i> ${formattedDateLab}
</div>
<div class="time-v1-calendar-grid">
<div class="time-v1-time-axis">
`;
for (let i = 0; i <= TOTAL_HOURS; i++) {
const h = START_HOUR + i;
const top = i * HOUR_HEIGHT;
html += `<div class="time-v1-hour-marker" style="top: ${top}px">${h.toString().padStart(2, '0')}:00</div>`;
}
html += `</div>`;
techNames.forEach(tech => {
html += `
<div class="time-v1-tech-col" data-tech="${escapeHtml(tech)}" data-date="${dateStr}">
<div class="time-v1-tech-header">
<i class="bi bi-person-circle text-secondary"></i> ${escapeHtml(tech)}
</div>
<div class="time-v1-tech-body">
`;
techs[tech].forEach(entry => {
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
const status = entry.entry_status || entry.status || 'kladde';
let cssClass = 'time-v1-entry-kladde';
if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
const startObj = new Date(entry.start_tid);
let durationMin = 30; // default length
if (entry.faktisk_tid_min) {
durationMin = parseInt(entry.faktisk_tid_min);
} else if (entry.original_hours || entry.timer) {
durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
}
let startH = startObj.getHours();
let startM = startObj.getMinutes();
if (startH < START_HOUR) {
durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
startH = START_HOUR;
startM = 0;
}
let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
let heightPx = (durationMin / 60) * HOUR_HEIGHT;
if (topPx < 0) topPx = 0;
if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
}
if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
const endObj = new Date(startObj.getTime() + durationMin * 60000);
const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
html += `
<div class="time-v1-entry-block ${cssClass}" style="top: ${topPx}px; height: ${heightPx}px;" title="${desc}">
<div class="time-v1-entry-time">${timeStr}</div>
<div class="time-v1-entry-desc text-wrap">${desc}</div>
</div>
`;
}
});
html += `
</div>
</div>
`;
});
html += `</div>`;
if (unplaced.length > 0) {
html += `<div class="time-v1-unplaced-container">
<span class="text-muted small fw-semibold"><i class="bi bi-clock-history"></i> Uden tidsrum:</span>
`;
unplaced.forEach(u => {
const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
const hrs = u.original_hours || u.timer || 0;
html += `<div class="time-v1-unplaced-item">
<i class="bi bi-person text-secondary"></i> ${userName} &bull; ${hrs}t
</div>`;
});
html += `</div>`;
}
html += `</div>`;
});
timeline.innerHTML = html;
}
"""
if js_start != -1 and js_end != -1:
text = text[:js_start] + js_new + text[js_end:]
print("Timeline JS applied.")
# 2. timeManualFormV1 update
tf1_start = text.find('<form id="timeManualFormV1"')
tf1_end = text.find('</form>', tf1_start) + 7
new_tf1 = """<form id="timeManualFormV1" class="row g-2 align-items-end" onsubmit="createManualTimeV1(event); return false;">
<div class="col-xl-2 col-md-3 col-12">
<label class="form-label small mb-1">Medarbejder</label>
<select class="form-select form-select-sm" id="timeV1EmployeeId">
<option value="">Mig (nuværende bruger)</option>
{% for user in assignment_users %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-xl-2 col-md-3 col-6">
<label class="form-label small mb-1">Dato</label>
<input type="date" class="form-control form-control-sm" id="timeV1Date">
</div>
<div class="col-xl-1 col-md-2 col-3">
<label class="form-label small mb-1">Start</label>
<input type="time" class="form-control form-control-sm" id="timeV1Start">
</div>
<div class="col-xl-1 col-md-2 col-3">
<label class="form-label small mb-1">Slut</label>
<input type="time" class="form-control form-control-sm" id="timeV1End">
</div>
<div class="col-xl-1 col-md-2 col-6">
<label class="form-label small mb-1">Minutt.</label>
<input type="number" min="1" class="form-control form-control-sm" id="timeV1Minutes" placeholder="45" required>
</div>
<div class="col-xl-2 col-md-6 col-6">
<label class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="timeV1Description" placeholder="Hvad er udført?">
</div>
<div class="col-xl-2 col-md-4 col-12 d-flex gap-1">
<div class="w-50">
<label class="form-label small mb-1">Type</label>
<select class="form-select form-select-sm px-1" id="timeV1Type">
<option value="ukendt">Ukendt</option>
<option value="manuel" selected>Manuel</option>
<option value="opkald">Opkald</option>
<option value="mail">Mail</option>
<option value="indedesk">IndeDesk</option>
</select>
</div>
<div class="w-50">
<label class="form-label small mb-1">Status</label>
<select class="form-select form-select-sm px-1" id="timeV1Status">
<option value="kladde">Kladde</option>
<option value="afventer" selected>Afventer</option>
<option value="godkendt">Godkendt</option>
</select>
</div>
</div>
<div class="col-xl-1 col-md-2 col-12 d-grid">
<button class="btn btn-sm btn-primary" type="submit" title="Tilføj registrering"><i class="bi bi-plus-lg fs-6"></i></button>
</div>
</form>"""
if tf1_start != -1 and tf1_end != -1:
text = text[:tf1_start] + new_tf1 + text[tf1_end:]
print("timeManualFormV1 applied")
tf1_js_s = text.find('async function createManualTimeV1(event) {')
tf1_js_e = text.find(' document.addEventListener(\'DOMContentLoaded\'', tf1_js_s)
new_tf1_js = """function bindTimeV1Calculations() {
const startIn = document.getElementById('timeV1Start');
const endIn = document.getElementById('timeV1End');
const minIn = document.getElementById('timeV1Minutes');
if (!startIn || !endIn || !minIn) return;
const parseTime = (val) => {
if (!val) return null;
const [h,m] = val.split(':').map(Number);
return (h * 60) + m;
};
const toTimeStr = (totalMins) => {
const h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
const recalculate = (trigger) => {
const s = parseTime(startIn.value);
const e = parseTime(endIn.value);
const dur = parseInt(minIn.value);
if (trigger === 'start' || trigger === 'end') {
if (s !== null && e !== null) {
let diff = e - s;
if (diff < 0) diff += 24*60;
minIn.value = diff;
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while (base < 0) base += 24*60;
startIn.value = toTimeStr(base);
}
} else if (trigger === 'min') {
if (s !== null && !isNaN(dur) && dur > 0) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while(base < 0) base+=24*60;
startIn.value = toTimeStr(base);
}
}
};
startIn.addEventListener('change', () => recalculate('start'));
endIn.addEventListener('change', () => recalculate('end'));
minIn.addEventListener('input', () => recalculate('min'));
}
async function createManualTimeV1(event) {
event.preventDefault();
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
if (minutes <= 0) {
alert('Indtast minutter over 0');
return;
}
const dateVal = document.getElementById('timeV1Date')?.value || null;
const tStart = document.getElementById('timeV1Start')?.value;
const tEnd = document.getElementById('timeV1End')?.value;
let startObj = null;
let endObj = null;
if (dateVal && tStart) {
try {
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
}
if (dateVal && tEnd) {
try {
const l = new Date(`${dateVal}T${tEnd}:00`);
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
}
const payload = {
sag_id: timeCaseId,
medarbejder_id: getTimeV1EmployeeId(),
faktisk_tid_min: minutes,
worked_date: dateVal,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
entry_status: document.getElementById('timeV1Status')?.value || 'afventer',
beskrivelse: document.getElementById('timeV1Description')?.value || null,
kilde: 'manuel',
start_tid: startObj,
slut_tid: endObj
};
try {
const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const minutesInput = document.getElementById('timeV1Minutes');
const descInput = document.getElementById('timeV1Description');
const startIn = document.getElementById('timeV1Start');
const endIn = document.getElementById('timeV1End');
if (minutesInput) minutesInput.value = '';
if (descInput) descInput.value = '';
if (startIn) startIn.value = '';
if (endIn) endIn.value = '';
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
}
}
\n"""
if tf1_js_s != -1 and tf1_js_e != -1:
text = text[:tf1_js_s] + new_tf1_js + text[tf1_js_e:]
print("createManualTimeV1 js applied.")
# Inject bindTimeV1Calculations in DOMContentLoaded (lines 6830ish)
# We find: document.addEventListener('DOMContentLoaded', () => {
# const dateInput = document.getElementById('timeV1Date');
dom_inject = """document.addEventListener('DOMContentLoaded', () => {
bindTimeV1Calculations();
const dateInput = document.getElementById('timeV1Date');"""
text = text.replace("document.addEventListener('DOMContentLoaded', () => {\n const dateInput = document.getElementById('timeV1Date');", dom_inject)
# 3. Modal timeForm Update
mhtml_start = text.find('<form id="timeForm">')
mhtml_end = text.find('</form>', mhtml_start) + 7
new_mhtml = """<form id="timeForm">
<input type="hidden" id="time_sag_id" value="{{ case.id }}">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="time_date" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<span class="input-group-text">Min.</span>
<input type="number" class="form-control" id="time_total_minutes" min="1" placeholder="45" step="1" required>
</div>
</div>
<div class="col-6">
<label class="form-label">Starttid</label>
<input type="time" class="form-control" id="time_start_input">
</div>
<div class="col-6">
<label class="form-label">Sluttid</label>
<input type="time" class="form-control" id="time_end_input">
</div>
<div class="col-6">
<label class="form-label">Type</label>
<select class="form-select" id="time_work_type">
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Afregning</label>
<select class="form-select" id="time_billing_method">
<option value="invoice" selected>Faktura</option>
{% if prepaid_cards %}
<optgroup label="Klippekort">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">💳 Klippekort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t tilbage{% if card.expires_at %} • Udløber {{ card.expires_at }}{% endif %})</option>
{% endfor %}
</optgroup>
{% endif %}
{% if fixed_price_agreements %}
<optgroup label="Fastpris Aftaler">
{% for agr in fixed_price_agreements %}
<option value="fpa_{{ agr.id }}">📋 Fastpris #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t tilbage / {{ '%.0f' % agr.monthly_hours }}t/måned)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
</div>
</form>"""
if mhtml_start != -1 and mhtml_end != -1:
text = text[:mhtml_start] + new_mhtml + text[mhtml_end:]
print("timeForm modal html applied.")
# Replace saveTime to send start_tid / slut_tid using the new fields
old_save_time_start = text.find('async function saveTime() {')
if old_save_time_start != -1:
# Safely find the end of saveTime function body
bracket_count = 0
in_function = False
old_save_time_end = -1
for i in range(old_save_time_start, len(text)):
if text[i] == '{':
bracket_count += 1
in_function = True
elif text[i] == '}':
bracket_count -= 1
if in_function and bracket_count == 0:
old_save_time_end = i + 1
break
if old_save_time_end != -1:
new_save_time_js = """ function bindTimeModalCalculations() {
const startIn = document.getElementById('time_start_input');
const endIn = document.getElementById('time_end_input');
const minIn = document.getElementById('time_total_minutes');
if (!startIn || !endIn || !minIn) return;
const parseTime = (val) => {
if (!val) return null;
const [h,m] = val.split(':').map(Number);
return (h * 60) + m;
};
const toTimeStr = (totalMins) => {
const h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
const recalculate = (trigger) => {
const s = parseTime(startIn.value);
const e = parseTime(endIn.value);
const dur = parseInt(minIn.value);
if (trigger === 'start' || trigger === 'end') {
if (s !== null && e !== null) {
let diff = e - s;
if (diff < 0) diff += 24*60;
minIn.value = diff;
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while (base < 0) base += 24*60;
startIn.value = toTimeStr(base);
}
} else if (trigger === 'min') {
if (s !== null && !isNaN(dur) && dur > 0) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while(base < 0) base+=24*60;
startIn.value = toTimeStr(base);
}
}
};
startIn.addEventListener('change', () => recalculate('start'));
endIn.addEventListener('change', () => recalculate('end'));
minIn.addEventListener('input', () => recalculate('min'));
}
document.addEventListener('DOMContentLoaded', bindTimeModalCalculations);
async function saveTime() {
const mInput = document.getElementById('time_total_minutes');
const minVal = parseInt(mInput ? mInput.value : 0);
if (!minVal || minVal <= 0) {
alert('Indtast en gyldig varighed (minutter).');
return;
}
const totalHours = minVal / 60;
const dateVal = document.getElementById('time_date').value;
// extract optional start/end limits
const tStart = document.getElementById('time_start_input')?.value;
const tEnd = document.getElementById('time_end_input')?.value;
let startObj = null;
let endObj = null;
if (dateVal && tStart) {
try {
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
}
if (dateVal && tEnd) {
try {
const l = new Date(`${dateVal}T${tEnd}:00`);
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
}
const sagId = document.getElementById('time_sag_id').value;
const payload = {
sag_id: parseInt(sagId),
// Note: saveTime modal expects 'timer' as totalHours currently, let's keep compatibility:
timer: totalHours,
faktisk_tid_min: minVal,
worked_date: dateVal,
start_tid: startObj,
slut_tid: endObj,
description: document.getElementById('time_desc').value,
work_type: document.getElementById('time_work_type').value,
billing_method: document.getElementById('time_billing_method').value
};
try {
const res = await fetch(`/api/v1/cases/${sagId}/time`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if(res.ok) {
window.location.reload();
} else {
alert("Fejl ved registrering af tid");
}
} catch(err) {
console.error(err);
alert("Forbindelsesfejl");
}
}"""
text = text[:old_save_time_start] + new_save_time_js + text[old_save_time_end:]
print("saveTime js logic replaced.")
# We also need to fix `showAddTimeModal()` reset fields:
show_add_modal = text.find('if(document.getElementById(\'time_hours_input\')) {')
show_add_modal_end = text.find('}', show_add_modal) + 1
if show_add_modal != -1:
new_reset = """if(document.getElementById('time_total_minutes')) {
document.getElementById('time_total_minutes').value = '';
document.getElementById('time_start_input').value = '';
document.getElementById('time_end_input').value = '';
}"""
text = text[:show_add_modal] + new_reset + text[show_add_modal_end:]
# And delete old 'updateTimeTotal()' function
old_update_tot_s = text.find('function updateTimeTotal() {')
if old_update_tot_s != -1:
old_update_tot_e = text.find('}', text.find('}', old_update_tot_s) + 1) + 1
# We'll just comment it out to avoid bracket mess tracking
if text[old_update_tot_e-1] == '}':
text = text[:old_update_tot_s] + "/* removed updateTimeTotal */\n" + text[old_update_tot_e:]
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("Done writing to file safely.")

207
patch_time_form.py Normal file
View File

@ -0,0 +1,207 @@
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
html_start = text.find('<form id="timeManualFormV1"')
html_end = text.find('</form>', html_start) + 7
new_html = """<form id="timeManualFormV1" class="row g-2 align-items-end" onsubmit="createManualTimeV1(event); return false;">
<div class="col-xl-2 col-md-3 col-12">
<label class="form-label small mb-1">Medarbejder</label>
<select class="form-select form-select-sm" id="timeV1EmployeeId">
<option value="">Mig (nuværende bruger)</option>
{% for user in assignment_users %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-xl-2 col-md-3 col-6">
<label class="form-label small mb-1">Dato</label>
<input type="date" class="form-control form-control-sm" id="timeV1Date">
</div>
<div class="col-xl-1 col-md-2 col-3">
<label class="form-label small mb-1">Start</label>
<input type="time" class="form-control form-control-sm" id="timeV1Start">
</div>
<div class="col-xl-1 col-md-2 col-3">
<label class="form-label small mb-1">Slut</label>
<input type="time" class="form-control form-control-sm" id="timeV1End">
</div>
<div class="col-xl-1 col-md-2 col-6">
<label class="form-label small mb-1">Minutt.</label>
<input type="number" min="1" class="form-control form-control-sm" id="timeV1Minutes" placeholder="45" required>
</div>
<div class="col-xl-2 col-md-6 col-6">
<label class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="timeV1Description" placeholder="Hvad er udført?">
</div>
<div class="col-xl-2 col-md-4 col-12 d-flex gap-1">
<div class="w-50">
<label class="form-label small mb-1">Type</label>
<select class="form-select form-select-sm px-1" id="timeV1Type">
<option value="ukendt">Ukendt</option>
<option value="manuel" selected>Manuel</option>
<option value="opkald">Opkald</option>
<option value="mail">Mail</option>
<option value="indedesk">IndeDesk</option>
</select>
</div>
<div class="w-50">
<label class="form-label small mb-1">Status</label>
<select class="form-select form-select-sm px-1" id="timeV1Status">
<option value="kladde">Kladde</option>
<option value="afventer" selected>Afventer</option>
<option value="godkendt">Godkendt</option>
</select>
</div>
</div>
<div class="col-xl-1 col-md-2 col-12 d-grid">
<button class="btn btn-sm btn-primary" type="submit" title="Tilføj registrering"><i class="bi bi-plus-lg fs-6"></i></button>
</div>
</form>"""
if html_start != -1 and html_end != -1:
text = text[:html_start] + new_html + text[html_end:]
print("HTML updated.")
js_start = text.find('async function createManualTimeV1(event) {')
js_end = text.find(' document.addEventListener(\'DOMContentLoaded\'', js_start)
# Notice here the JS checks for start_tid / slut_tid to populate them.
new_js = """function bindTimeV1Calculations() {
const startIn = document.getElementById('timeV1Start');
const endIn = document.getElementById('timeV1End');
const minIn = document.getElementById('timeV1Minutes');
if (!startIn || !endIn || !minIn) return;
const parseTime = (val) => {
if (!val) return null;
const [h,m] = val.split(':').map(Number);
return (h * 60) + m;
};
const toTimeStr = (totalMins) => {
const h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
const recalculate = (trigger) => {
const s = parseTime(startIn.value);
const e = parseTime(endIn.value);
const dur = parseInt(minIn.value);
if (trigger === 'start' || trigger === 'end') {
if (s !== null && e !== null) {
let diff = e - s;
if (diff < 0) diff += 24*60;
minIn.value = diff;
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while (base < 0) base += 24*60;
startIn.value = toTimeStr(base);
}
} else if (trigger === 'min') {
if (s !== null && !isNaN(dur) && dur > 0) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while(base < 0) base+=24*60;
startIn.value = toTimeStr(base);
}
}
};
startIn.addEventListener('change', () => recalculate('start'));
endIn.addEventListener('change', () => recalculate('end'));
minIn.addEventListener('input', () => recalculate('min'));
}
async function createManualTimeV1(event) {
event.preventDefault();
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
if (minutes <= 0) {
alert('Indtast minutter over 0');
return;
}
const dateVal = document.getElementById('timeV1Date')?.value || null;
const tStart = document.getElementById('timeV1Start')?.value;
const tEnd = document.getElementById('timeV1End')?.value;
let startObj = null;
let endObj = null;
if (dateVal && tStart) {
try {
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
}
if (dateVal && tEnd) {
try {
const l = new Date(`${dateVal}T${tEnd}:00`);
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
}
const payload = {
sag_id: timeCaseId,
medarbejder_id: getTimeV1EmployeeId(),
faktisk_tid_min: minutes,
worked_date: dateVal,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
entry_status: document.getElementById('timeV1Status')?.value || 'afventer',
beskrivelse: document.getElementById('timeV1Description')?.value || null,
kilde: 'manuel',
start_tid: startObj,
slut_tid: endObj
};
try {
const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const minutesInput = document.getElementById('timeV1Minutes');
const descInput = document.getElementById('timeV1Description');
const startIn = document.getElementById('timeV1Start');
const endIn = document.getElementById('timeV1End');
if (minutesInput) minutesInput.value = '';
if (descInput) descInput.value = '';
if (startIn) startIn.value = '';
if (endIn) endIn.value = '';
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
}
}
\n"""
if js_start != -1 and js_end != -1:
text = text[:js_start] + new_js + text[js_end:]
print("JS updated.")
dom_start = text.find('document.addEventListener(\'DOMContentLoaded\'')
if dom_start != -1:
dom_body_start = text.find('{', dom_start) + 1
# Check if we already injected it
if 'bindTimeV1Calculations();' not in text[dom_start:dom_start+200]:
text = text[:dom_body_start] + "\n bindTimeV1Calculations();" + text[dom_body_start:]
print("DOMContentLoaded updated.")
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("File saved successfully.")

171
patch_time_modal.py Normal file
View File

@ -0,0 +1,171 @@
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
# Replace HTML for timeForm
html_start = text.find('<form id="timeForm">')
html_end = text.find('</form>', html_start) + 7
new_html = """<form id="timeForm">
<input type="hidden" id="time_sag_id" value="{{ case.id }}">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="time_date" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<span class="input-group-text">Min.</span>
<input type="number" class="form-control" id="time_total_minutes" min="1" placeholder="45" step="1" required>
</div>
</div>
<div class="col-6">
<label class="form-label">Starttid</label>
<input type="time" class="form-control" id="time_start_input">
</div>
<div class="col-6">
<label class="form-label">Sluttid</label>
<input type="time" class="form-control" id="time_end_input">
</div>
<div class="col-6">
<label class="form-label">Type</label>
<select class="form-select" id="time_work_type">
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Afregning</label>
<select class="form-select" id="time_billing_method">
<option value="invoice" selected>Faktura</option>
{% if prepaid_cards %}
<optgroup label="Klippekort">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">💳 Klippekort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t tilbage{% if card.expires_at %} • Udløber {{ card.expires_at }}{% endif %})</option>
{% endfor %}
</optgroup>
{% endif %}
{% if fixed_price_agreements %}
<optgroup label="Fastpris Aftaler">
{% for agr in fixed_price_agreements %}
<option value="fpa_{{ agr.id }}">📋 Fastpris #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t tilbage / {{ '%.0f' % agr.monthly_hours }}t/måned)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
</div>
</form>"""
if html_start != -1 and html_end != -1:
text = text[:html_start] + new_html + text[html_end:]
print("Replaced timeForm HTML.")
# Modify reset logic in showAddTimeModal
reset_start = text.find('if(document.getElementById(\'time_hours_input\')) {')
reset_end = text.find('}', reset_start) + 1
if reset_start != -1:
new_reset = """if(document.getElementById('time_total_minutes')) {
document.getElementById('time_total_minutes').value = '';
document.getElementById('time_start_input').value = '';
document.getElementById('time_end_input').value = '';
}"""
text = text[:reset_start] + new_reset + text[reset_end:]
print("Replaced modal form reset.")
# Delete old updateTimeTotal function, add bindTimeModalCalculations
updateTotalStart = text.find('function updateTimeTotal() {')
updateTotalEnd = text.find('}', updateTotalStart) + 1
if updateTotalStart != -1:
new_updateTotal = """function bindTimeModalCalculations() {
const startIn = document.getElementById('time_start_input');
const endIn = document.getElementById('time_end_input');
const minIn = document.getElementById('time_total_minutes');
if (!startIn || !endIn || !minIn) return;
const parseTime = (val) => {
if (!val) return null;
const [h,m] = val.split(':').map(Number);
return (h * 60) + m;
};
const toTimeStr = (totalMins) => {
const h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
const recalculate = (trigger) => {
const s = parseTime(startIn.value);
const e = parseTime(endIn.value);
const dur = parseInt(minIn.value);
if (trigger === 'start' || trigger === 'end') {
if (s !== null && e !== null) {
let diff = e - s;
if (diff < 0) diff += 24*60;
minIn.value = diff;
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while (base < 0) base += 24*60;
startIn.value = toTimeStr(base);
}
} else if (trigger === 'min') {
if (s !== null && !isNaN(dur) && dur > 0) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while(base < 0) base+=24*60;
startIn.value = toTimeStr(base);
}
}
};
startIn.addEventListener('change', () => recalculate('start'));
endIn.addEventListener('change', () => recalculate('end'));
minIn.addEventListener('input', () => recalculate('min'));
}"""
text = text[:updateTotalStart] + new_updateTotal + text[updateTotalEnd:]
print("Replaced updateTimeTotal with bindTimeModalCalculations")
# Fix listeners initialization
dom_start = text.find('const hInput = document.getElementById(\'time_hours_input\');')
dom_end = text.find('if(mInput) mInput.addEventListener(\'input\', updateTimeTotal);', dom_start) + 63
if dom_start != -1:
text = text[:dom_start] + "bindTimeModalCalculations();" + text[dom_end:]
print("Fixed DOM listeners")
# Replace saveTime body part logic: calculate minutes explicitly from `time_total_minutes`
save_start = text.find('async function saveTime() {')
save_end = text.find('const isInternal = document.getElementById(\'time_internal\')?.checked || false;', save_start)
if save_start != -1:
new_save = """async function saveTime() {
const mInput = document.getElementById('time_total_minutes');
const minVal = parseInt(mInput ? mInput.value : 0);
if (!minVal || minVal <= 0) {
alert('Indtast en gyldig varighed (minutter).');
return;
}
const totalHours = minVal / 60;
"""
text = text[:save_start] + new_save + text[save_end:]
print("Updated saveTime first half.")
# Note: saveTime uses `POST /api/v1/cases/${sagId}/time` or similar, wait let me check the actual fetch path.
# Let's check `saveTime` first before committing blindly. I will just do the above first, then verify `saveTime`.
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)

1
patcher.py Normal file
View File

@ -0,0 +1 @@
import os

9
print_saveTime.py Normal file
View File

@ -0,0 +1,9 @@
import re
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
s = text.find('async function saveTime()')
if s != -1:
e = text.find('async function createTodoStep', s)
if e == -1: e = s + 2000
print(text[s:e])

1
result.txt Normal file
View File

@ -0,0 +1 @@
2

15
run_anydesk_import.py Normal file
View File

@ -0,0 +1,15 @@
"""Run AnyDesk session import directly (bypasses HTTP auth)"""
import asyncio, sys, os
sys.path.insert(0, os.path.dirname(__file__))
os.environ.setdefault("DATABASE_URL", "postgresql://bmc_hub:bmc_hub@localhost:5433/bmc_hub")
from app.services.anydesk import AnyDeskService
async def main():
svc = AnyDeskService()
print("Credentials:", svc._get_credentials())
print("\nFetching sessions (last 30 days, up to 1000)...")
result = await svc.fetch_sessions_from_api(days=30, limit=1000)
print(f"\nResult: {result}")
asyncio.run(main())

43
script_0.js Normal file
View File

@ -0,0 +1,43 @@
let caseCurrentUserId = null;
async function ensureCaseCurrentUserId() {
if (caseCurrentUserId !== null) return caseCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
caseCurrentUserId = Number(me?.id) || null;
return caseCurrentUserId;
} catch (e) {
return null;
}
}
async function ringOutFromCase(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureCaseCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}

1433
script_1.js Normal file

File diff suppressed because it is too large Load Diff

918
script_10.js Normal file
View File

@ -0,0 +1,918 @@
(function () {
'use strict';
let _openPopover = null;
// ── helpers ───────────────────────────────────────────────────────
function closeAllPopovers() {
document.querySelectorAll('.rel-qa-menu').forEach(el => el.remove());
_openPopover = null;
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.rel-qa-menu') && !e.target.closest('.btn-rel-action')) closeAllPopovers();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAllPopovers();
});
function popoverPos(btn) {
const r = btn.getBoundingClientRect();
return { top: r.bottom + window.scrollY + 4, left: r.left + window.scrollX };
}
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ── load global entity tags into rel-tag-row divs (using global tag system) ──
async function loadAllRelationTags() {
const rows = Array.from(document.querySelectorAll('.rel-tag-row'));
if (!rows.length) return;
// Wait briefly for tag-picker.js to initialize
const renderFn = () => window.renderEntityTags;
await new Promise(res => { const t = setInterval(() => { if (renderFn()) { clearInterval(t); res(); } }, 50); setTimeout(() => { clearInterval(t); res(); }, 2000); });
await Promise.all(rows.map(async el => {
const caseId = parseInt(el.id.replace('rel-tags-', ''));
if (isNaN(caseId) || !window.renderEntityTags) return;
await window.renderEntityTags('case', caseId, el.id);
}));
}
// ── tag button → opens global tag picker ──────────────────────────
window.openRelTagPopover = function(caseId) {
if (!window.showTagPicker) return;
window.showTagPicker('case', caseId, () => {
if (window.renderEntityTags) window.renderEntityTags('case', caseId, 'rel-tags-' + caseId);
});
};
// ── quick action menu ─────────────────────────────────────────────
const QA_ITEMS = [
{ icon: 'bi-person-check', label: 'Tildel sag', action: 'assign' },
{ icon: 'bi-clock', label: 'Tidregistrering', action: 'time' },
{ icon: 'bi-chat-left-text', label: 'Kommentar', action: 'note' },
{ icon: 'bi-bell', label: 'Påmindelse', action: 'reminder' },
{ icon: 'bi-graph-up-arrow', label: 'Salgspipeline', action: 'pipeline' },
{ icon: 'bi-paperclip', label: 'Filer', action: 'files' },
{ icon: 'bi-cpu', label: 'Hardware', action: 'hardware' },
{ icon: 'bi-check2-square', label: 'Opgave', action: 'todo' },
{ icon: 'bi-lightbulb', label: 'Løsning', action: 'solution' },
{ icon: 'bi-bag', label: 'Varekøb & salg', action: 'sales' },
{ icon: 'bi-arrow-repeat', label: 'Abonnement', action: 'subscription' },
{ icon: 'bi-envelope', label: 'Send email', action: 'email' },
];
// cache pipeline presence per caseId so we only fetch once per page load
const _pipelineCache = {};
window.openRelQaMenu = async function(caseId, caseTitle, btn) {
closeAllPopovers();
btn.classList.add('active');
const pos = popoverPos(btn);
const menu = document.createElement('div');
menu.className = 'rel-qa-menu';
menu.style.cssText = `position:absolute;top:${pos.top}px;left:${Math.max(0, pos.left - 120)}px;`;
menu.innerHTML = `<div style="font-size:.72rem;font-weight:700;color:var(--accent);padding:4px 12px 4px;">SAG-${caseId}</div>`
+ `<div style="font-size:.72rem;color:var(--text-secondary,#aaa);padding:2px 12px 4px;"><span class="spinner-border spinner-border-sm" style="width:.6rem;height:.6rem;border-width:.1em;"></span></div>`;
document.body.appendChild(menu);
_openPopover = menu;
// Fetch case data to check pipeline presence (cached)
if (!(_pipelineCache[caseId] !== undefined)) {
try {
const r = await fetch(`/api/v1/sag/${caseId}`, { credentials: 'include' });
if (r.ok) {
const d = await r.json();
_pipelineCache[caseId] = !!(d.pipeline_stage_id || d.pipeline_amount || d.pipeline_description);
} else {
_pipelineCache[caseId] = false;
}
} catch { _pipelineCache[caseId] = false; }
}
const hasPipeline = _pipelineCache[caseId];
// Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
const extra = hasPipeline
? `<div class="qa-item" style="opacity:.55;font-style:italic;" onclick="window.open('/sag/${caseId}','_blank')"><i class="bi bi-graph-up-arrow"></i>Pipeline (se sagen)</div>`
: '';
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
menu.innerHTML = `<div style="font-size:.72rem;font-weight:700;color:var(--accent);padding:4px 12px 4px;">SAG-${caseId}</div>`
+ items.map(item =>
`<div class="qa-item" onclick="relQaAction('${item.action}',${caseId},'${caseTitle.replace(/'/g,"\\'")}')"><i class="bi ${item.icon}"></i>${esc(item.label)}</div>`
).join('')
+ extra;
};
function getRelQaPrimaryButton() {
const sidePanel = document.getElementById('caseAddSidePanel');
if (sidePanel && sidePanel.classList.contains('open')) {
return sidePanel.querySelector('#relQaModalFooter .btn-primary');
}
return document.querySelector('#relQaModalEl .btn-primary');
}
function closeRelQaSurfaceAfterSave() {
const sidePanel = document.getElementById('caseAddSidePanel');
const panelOpen = !!(sidePanel && sidePanel.classList.contains('open'));
const relModalEl = document.getElementById('relQaModalEl');
const relModalInstance = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
if (relModalInstance) {
relModalInstance.hide();
}
// In sidepanel mode, refresh to reflect new persisted data across modules.
if (panelOpen) {
setTimeout(() => window.location.reload(), 120);
}
}
window.relQaAction = function(action, caseId, caseTitle) {
closeAllPopovers();
if (action === 'time') openRelTimeModal(caseId, caseTitle);
else if (action === 'email') openRelEmailModal(caseId, caseTitle);
else if (action === 'note') openRelNoteModal(caseId, caseTitle);
else if (action === 'reminder') openRelReminderModal(caseId, caseTitle);
else if (action === 'todo') openRelTodoModal(caseId, caseTitle);
else if (action === 'assign') openRelAssignModal(caseId, caseTitle);
else if (action === 'pipeline') openRelPipelineModal(caseId, caseTitle);
else if (action === 'files') openRelFilesModal(caseId, caseTitle);
else if (action === 'hardware') openRelHardwareModal(caseId, caseTitle);
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle);
else window.open(`/sag/${caseId}`, '_blank');
};
// ── Quick Pipeline modal ──────────────────────────────────────────
window.openRelPipelineModal = function(caseId, caseTitle) {
_showRelModal(
`<i class="bi bi-graph-up-arrow me-2"></i>Salgspipeline`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2">
<label class="form-label small fw-semibold">Stage</label>
<select id="rqp_stage" class="form-select form-select-sm">
<option value="">-- Vælg stage --</option>
<option value="1">Ny</option>
<option value="2">Afklaring</option>
<option value="3">Tilbud</option>
<option value="4">Commit</option>
<option value="5">Vundet</option>
<option value="6">Tabt</option>
<option value="7">Opsalg</option>
<option value="8">Lead</option>
<option value="9">Kontakt</option>
<option value="10">Forhandling</option>
</select>
</div>
<div class="row g-2 mb-2">
<div class="col-7">
<label class="form-label small fw-semibold">Beløb (DKK)</label>
<input type="number" id="rqp_amount" class="form-control form-control-sm" min="0" step="0.01" placeholder="0">
</div>
<div class="col-5">
<label class="form-label small fw-semibold">Sandsynlighed %</label>
<input type="number" id="rqp_prob" class="form-control form-control-sm" min="0" max="100" placeholder="0">
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Note</label>
<textarea id="rqp_desc" class="form-control form-control-sm" rows="2" placeholder="Pipeline-note…"></textarea>
</div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelPipeline(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
);
};
window._submitRelPipeline = async function(caseId) {
const stage = document.getElementById('rqp_stage').value;
const amount = document.getElementById('rqp_amount').value;
const prob = document.getElementById('rqp_prob').value;
const desc = document.getElementById('rqp_desc').value;
const payload = {};
if (stage) payload.stage_id = parseInt(stage);
if (amount) payload.amount = parseFloat(amount);
if (prob) payload.probability = parseInt(prob);
if (desc) payload.description = desc;
if (!Object.keys(payload).length) { if (typeof showNotification === 'function') showNotification('Udfyld mindst ét felt', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}/pipeline`, { method: 'PATCH', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Pipeline opdateret ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Files modal ─────────────────────────────────────────────
window.openRelFilesModal = function(caseId, caseTitle) {
_showRelModal(
`<i class="bi bi-paperclip me-2"></i>Upload fil`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2">
<label class="form-label small fw-semibold">Vælg fil</label>
<input type="file" id="rqf_file" class="form-control form-control-sm" multiple>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Beskrivelse (valgfri)</label>
<input type="text" id="rqf_desc" class="form-control form-control-sm" placeholder="Fil-note…">
</div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelFiles(${caseId})"><i class="bi bi-upload me-1"></i>Upload</button>`
);
};
window._submitRelFiles = async function(caseId) {
const fileInput = document.getElementById('rqf_file');
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
let success = 0; let failed = 0;
for (const file of fileInput.files) {
try {
const fd = new FormData();
fd.append('file', file);
const desc = document.getElementById('rqf_desc').value;
if (desc) fd.append('description', desc);
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
if (r.ok) success++; else failed++;
} catch { failed++; }
}
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') {
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success');
else showNotification(`${success} ok, ${failed} fejlede`, 'warning');
}
};
// ── Quick Hardware modal ──────────────────────────────────────────
window.openRelHardwareModal = async function(caseId, caseTitle) {
_showRelModal(
`<i class="bi bi-cpu me-2"></i>Hardware`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2">
<label class="form-label small fw-semibold">Søg hardware</label>
<input type="text" id="rqhw_search" class="form-control form-control-sm" placeholder="Serienummer, navn…" autocomplete="off">
<div id="rqhw_results" class="mt-1" style="max-height:180px;overflow-y:auto;border:1px solid var(--border,#dee2e6);border-radius:6px;display:none;"></div>
</div>
<div id="rqhw_selected" class="text-muted small"></div>
<div class="mb-2 mt-2">
<label class="form-label small fw-semibold">Note (valgfri)</label>
<input type="text" id="rqhw_note" class="form-control form-control-sm" placeholder="Note om hardware…">
</div>
<input type="hidden" id="rqhw_id" value="">`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelHardware(${caseId})"><i class="bi bi-check2 me-1"></i>Tilknyt</button>`
);
// Wire up search
const inp = document.getElementById('rqhw_search');
const res = document.getElementById('rqhw_results');
let _hwTimer;
inp.addEventListener('input', () => {
clearTimeout(_hwTimer);
_hwTimer = setTimeout(async () => {
const q = inp.value.trim();
if (q.length < 2) { res.style.display='none'; return; }
try {
const r = await fetch(`/api/v1/search/hardware?q=${encodeURIComponent(q)}`, { credentials: 'include' });
if (!r.ok) return;
const items = await r.json();
if (!items.length) { res.innerHTML = '<div class="p-2 text-muted small">Ingen resultater</div>'; res.style.display='block'; return; }
res.innerHTML = items.slice(0,10).map(h =>
`<div class="p-2 border-bottom hw-opt" style="cursor:pointer;font-size:.82rem;" data-id="${h.id}" data-label="${esc(h.name||h.serial_number||h.id)}">${esc(h.name||'')} <span class="text-muted">${esc(h.serial_number||'')}</span></div>`
).join('');
res.style.display = 'block';
res.querySelectorAll('.hw-opt').forEach(el => el.addEventListener('click', () => {
document.getElementById('rqhw_id').value = el.dataset.id;
document.getElementById('rqhw_selected').textContent = '✓ Valgt: ' + el.dataset.label;
inp.value = el.dataset.label;
res.style.display = 'none';
}));
} catch {}
}, 300);
});
};
window._submitRelHardware = async function(caseId) {
const hwId = document.getElementById('rqhw_id').value;
if (!hwId) { if (typeof showNotification === 'function') showNotification('Vælg hardware fra listen', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch(`/api/v1/sag/${caseId}/hardware`, {
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ hardware_id: parseInt(hwId), note: document.getElementById('rqhw_note').value })
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Hardware tilknyttet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Løsning modal ───────────────────────────────────────────
window.openRelSolutionModal = function(caseId, caseTitle) {
const today = new Date().toISOString().split('T')[0];
_showRelModal(
`<i class="bi bi-lightbulb me-2"></i>Løsning`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2">
<label class="form-label small fw-semibold">Titel</label>
<input type="text" id="rqs_title" class="form-control form-control-sm" placeholder="Løsningstitel…">
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Type</label>
<select id="rqs_type" class="form-select form-select-sm">
<option value="standard">Standard</option>
<option value="workaround">Workaround</option>
<option value="permanent">Permanent</option>
<option value="external">Ekstern</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Resultat</label>
<select id="rqs_result" class="form-select form-select-sm">
<option value="resolved">Løst</option>
<option value="partial">Delvist løst</option>
<option value="unresolved">Uløst</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Beskrivelse</label>
<textarea id="rqs_desc" class="form-control form-control-sm" rows="3" placeholder="Beskriv løsningen…"></textarea>
</div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelSolution(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
);
};
window._submitRelSolution = async function(caseId) {
const title = document.getElementById('rqs_title').value.trim();
if (!title) { if (typeof showNotification === 'function') showNotification('Angiv en titel', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch(`/api/v1/sag/${caseId}/solution`, {
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
sag_id: caseId,
title,
solution_type: document.getElementById('rqs_type').value,
result: document.getElementById('rqs_result').value,
description: document.getElementById('rqs_desc').value,
})
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Løsning gemt ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Varekøb & Salg modal ────────────────────────────────────
window.openRelSalesModal = function(caseId, caseTitle) {
const today = new Date().toISOString().split('T')[0];
_showRelModal(
`<i class="bi bi-bag me-2"></i>Varekøb &amp; salg`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2">
<label class="form-label small fw-semibold">Type</label>
<select id="rqsl_type" class="form-select form-select-sm">
<option value="sale">Salg</option>
<option value="purchase">Indkøb</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Beskrivelse</label>
<input type="text" id="rqsl_desc" class="form-control form-control-sm" placeholder="Varebeskrivelse…">
</div>
<div class="row g-2 mb-2">
<div class="col-4">
<label class="form-label small fw-semibold">Antal</label>
<input type="number" id="rqsl_qty" class="form-control form-control-sm" min="1" value="1" step="1">
</div>
<div class="col-4">
<label class="form-label small fw-semibold">Stykpris</label>
<input type="number" id="rqsl_uprice" class="form-control form-control-sm" min="0" step="0.01" placeholder="0.00">
</div>
<div class="col-4">
<label class="form-label small fw-semibold">Total (DKK)</label>
<input type="number" id="rqsl_total" class="form-control form-control-sm" min="0" step="0.01" placeholder="0.00">
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Dato</label>
<input type="date" id="rqsl_date" class="form-control form-control-sm" value="${today}">
</div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelSales(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
);
// Auto-calculate total when qty/uprice changes
setTimeout(() => {
const qtyEl = document.getElementById('rqsl_qty');
const uprEl = document.getElementById('rqsl_uprice');
const totEl = document.getElementById('rqsl_total');
function calcTotal() {
const q = parseFloat(qtyEl.value) || 0;
const u = parseFloat(uprEl.value) || 0;
if (q && u) totEl.value = (q * u).toFixed(2);
}
qtyEl.addEventListener('input', calcTotal);
uprEl.addEventListener('input', calcTotal);
}, 50);
};
window._submitRelSales = async function(caseId) {
const desc = document.getElementById('rqsl_desc').value.trim();
const total = parseFloat(document.getElementById('rqsl_total').value);
if (!desc) { if (typeof showNotification === 'function') showNotification('Angiv beskrivelse', 'warning'); return; }
if (!total) { if (typeof showNotification === 'function') showNotification('Angiv beløb', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch(`/api/v1/sag/${caseId}/sale-items`, {
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
type: document.getElementById('rqsl_type').value,
description: desc,
quantity: parseFloat(document.getElementById('rqsl_qty').value) || 1,
unit_price: parseFloat(document.getElementById('rqsl_uprice').value) || null,
amount: total,
line_date: document.getElementById('rqsl_date').value || null,
status: 'draft',
})
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Varelinje oprettet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Abonnement modal ────────────────────────────────────────
window.openRelSubscriptionModal = function(caseId, caseTitle) {
const today = new Date().toISOString().split('T')[0];
_showRelModal(
`<i class="bi bi-arrow-repeat me-2"></i>Abonnement`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-semibold">Faktureringsinterval</label>
<select id="rqsub_interval" class="form-select form-select-sm">
<option value="monthly">Månedlig</option>
<option value="quarterly">Kvartalsvis</option>
<option value="yearly">Årlig</option>
<option value="weekly">Ugentlig</option>
</select>
</div>
<div class="col-3">
<label class="form-label small fw-semibold">Fakturering dag</label>
<input type="number" id="rqsub_day" class="form-control form-control-sm" min="1" max="28" value="1">
</div>
<div class="col-3">
<label class="form-label small fw-semibold">Startdato</label>
<input type="date" id="rqsub_start" class="form-control form-control-sm" value="${today}">
</div>
</div>
<div class="border rounded p-2 mb-2">
<div class="small fw-semibold mb-1">Varelinje</div>
<div class="row g-1">
<div class="col-6"><input type="text" id="rqsub_li_desc" class="form-control form-control-sm" placeholder="Beskrivelse"></div>
<div class="col-3"><input type="number" id="rqsub_li_qty" class="form-control form-control-sm" placeholder="Antal" min="1" value="1"></div>
<div class="col-3"><input type="number" id="rqsub_li_price" class="form-control form-control-sm" placeholder="Pris" min="0" step="0.01"></div>
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Note (valgfri)</label>
<input type="text" id="rqsub_notes" class="form-control form-control-sm" placeholder="Intern note…">
</div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelSubscription(${caseId})"><i class="bi bi-check2 me-1"></i>Opret</button>`
);
};
window._submitRelSubscription = async function(caseId) {
const interval = document.getElementById('rqsub_interval').value;
const day = parseInt(document.getElementById('rqsub_day').value);
const startDate = document.getElementById('rqsub_start').value;
const liDesc = document.getElementById('rqsub_li_desc').value.trim();
const liQty = parseFloat(document.getElementById('rqsub_li_qty').value) || 1;
const liPrice = parseFloat(document.getElementById('rqsub_li_price').value) || 0;
if (!startDate) { if (typeof showNotification === 'function') showNotification('Angiv startdato', 'warning'); return; }
if (!liDesc || !liPrice) { if (typeof showNotification === 'function') showNotification('Udfyld varelinje (beskrivelse + pris)', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch('/api/v1/sag-subscriptions', {
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
sag_id: caseId,
billing_interval: interval,
billing_day: day,
start_date: startDate,
notes: document.getElementById('rqsub_notes').value || null,
line_items: [{ description: liDesc, quantity: liQty, unit_price: liPrice }]
})
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Abonnement oprettet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Time modal ──────────────────────────────────────────────
window.openRelTimeModal = function(caseId, caseTitle) {
const today = new Date().toISOString().split('T')[0];
_showRelModal(
`<i class="bi bi-clock me-2"></i>Tidregistrering`,
`<div class="mb-2"><label class="form-label small fw-semibold">Sag</label>
<input class="form-control form-control-sm" readonly value="SAG-${caseId} ${esc(caseTitle)}"></div>
<div class="row g-2 mb-2">
<div class="col-6"><label class="form-label small fw-semibold">Dato</label>
<input type="date" id="rqt_date" class="form-control form-control-sm" value="${today}"></div>
<div class="col-3"><label class="form-label small fw-semibold">Timer</label>
<input type="number" id="rqt_h" class="form-control form-control-sm" min="0" max="23" value="0"></div>
<div class="col-3"><label class="form-label small fw-semibold">Min</label>
<input type="number" id="rqt_m" class="form-control form-control-sm" min="0" max="59" step="15" value="30"></div>
</div>
<div class="mb-2"><label class="form-label small fw-semibold">Fakturering</label>
<select id="rqt_billing" class="form-select form-select-sm">
<option value="invoice">Fakturerbar</option>
<option value="internal">Intern</option>
<option value="prepaid">Forudbetalt</option>
</select></div>
<div class="mb-2"><label class="form-label small fw-semibold">Beskrivelse</label>
<textarea id="rqt_desc" class="form-control form-control-sm" rows="2"></textarea></div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelTime(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
);
};
window._submitRelTime = async function(caseId) {
const h = parseInt(document.getElementById('rqt_h').value) || 0;
const m = parseInt(document.getElementById('rqt_m').value) || 0;
const totalHours = parseFloat((h + m / 60).toFixed(4));
if (totalHours <= 0) {
if (typeof showNotification === 'function') showNotification('Angiv tid (timer/minutter)', 'warning');
return;
}
const billing = document.getElementById('rqt_billing')?.value || 'invoice';
const payload = {
sag_id: caseId,
worked_date: document.getElementById('rqt_date').value,
original_hours: totalHours,
description: document.getElementById('rqt_desc').value,
billing_method: billing,
is_internal: billing === 'internal',
};
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try {
const r = await fetch('/api/v1/timetracking/entries/internal', { method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Tid registreret ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved registrering', 'error');
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check2 me-1"></i>Gem'; }
}
} catch { if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Gem'; } }
};
// ── Quick Email modal ─────────────────────────────────────────────
window.openRelEmailModal = function(caseId, caseTitle) {
const defaultRecipient = typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : '';
const defaultSubject = `Sag #${caseId}: `;
const attachmentOptions = Array.isArray(sagFilesCache) && sagFilesCache.length
? sagFilesCache
.map((file) => {
const fileId = Number(file.id);
const filename = esc(file.filename || `Fil ${fileId}`);
return `<option value="${fileId}">${filename}</option>`;
})
.join('')
: '<option disabled>Ingen sagsfiler</option>';
_showRelModal(
`<i class="bi bi-envelope me-2"></i>Email`,
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="row g-2 mb-2">
<div class="col-12"><label class="form-label small fw-semibold">Til</label>
<input type="text" id="rqe_to" class="form-control form-control-sm" placeholder="modtager@eksempel.dk" value="${esc(defaultRecipient)}"></div>
<div class="col-6"><label class="form-label small fw-semibold">Cc</label>
<input type="text" id="rqe_cc" class="form-control form-control-sm" placeholder="cc@eksempel.dk"></div>
<div class="col-6"><label class="form-label small fw-semibold">Bcc</label>
<input type="text" id="rqe_bcc" class="form-control form-control-sm" placeholder="bcc@eksempel.dk"></div>
</div>
<div class="mb-2"><label class="form-label small fw-semibold">Emne</label>
<input type="text" id="rqe_subject" class="form-control form-control-sm" value="${esc(defaultSubject)}"></div>
<div class="mb-2"><label class="form-label small fw-semibold">Vedhaeftninger</label>
<select id="rqe_attachment_ids" class="form-select form-select-sm" multiple>${attachmentOptions}</select>
</div>
<div class="mb-2"><label class="form-label small fw-semibold">Besked</label>
<textarea id="rqe_body" class="form-control form-control-sm" rows="6" placeholder="Skriv besked..."></textarea></div>
<div id="rqe_status" class="small text-muted"></div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelEmail(${caseId})"><i class="bi bi-send me-1"></i>Send email</button>`
);
};
window._submitRelEmail = async function(caseId) {
const toInput = document.getElementById('rqe_to');
const ccInput = document.getElementById('rqe_cc');
const bccInput = document.getElementById('rqe_bcc');
const subjectInput = document.getElementById('rqe_subject');
const bodyInput = document.getElementById('rqe_body');
const attachmentSelect = document.getElementById('rqe_attachment_ids');
const statusEl = document.getElementById('rqe_status');
const saveBtn = getRelQaPrimaryButton();
if (!toInput || !subjectInput || !bodyInput || !statusEl) return;
const to = parseEmailField(toInput.value);
const cc = parseEmailField(ccInput?.value || '');
const bcc = parseEmailField(bccInput?.value || '');
const subject = (subjectInput.value || '').trim();
const bodyText = (bodyInput.value || '').trim();
const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || [])
.map((opt) => Number(opt.value))
.filter((id) => Number.isInteger(id) && id > 0);
if (!to.length) {
if (typeof showNotification === 'function') showNotification('Udfyld mindst en modtager.', 'warning');
return;
}
if (!subject) {
if (typeof showNotification === 'function') showNotification('Udfyld emne.', 'warning');
return;
}
if (!bodyText) {
if (typeof showNotification === 'function') showNotification('Udfyld besked.', 'warning');
return;
}
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender...';
}
statusEl.className = 'small text-muted';
statusEl.textContent = 'Sender e-mail...';
try {
const res = await fetch(`/api/v1/sag/${caseId}/emails/send`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
to,
cc,
bcc,
subject,
body_text: bodyText,
attachment_file_ids: attachmentFileIds,
thread_email_id: selectedLinkedEmailId || null,
thread_key: linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key || null
})
});
if (!res.ok) {
let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`;
try {
const responseText = await res.text();
if (responseText) {
try {
const err = JSON.parse(responseText);
if (err?.detail) {
message = err.detail;
} else if (err?.message) {
message = err.message;
}
} catch (_) {
message = responseText.slice(0, 500);
}
}
} catch (_) {
}
throw new Error(message);
}
statusEl.className = 'small text-success';
statusEl.textContent = 'E-mail sendt.';
if (typeof loadLinkedEmails === 'function') {
loadLinkedEmails();
}
if (typeof showNotification === 'function') showNotification('E-mail sendt.', 'success');
const relModalEl = document.getElementById('relQaModalEl');
const relModal = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
if (relModal) relModal.hide();
} catch (error) {
statusEl.className = 'small text-danger';
statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)';
if (typeof showNotification === 'function') showNotification(statusEl.textContent, 'error');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-send me-1"></i>Send email';
}
return;
}
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-send me-1"></i>Send email';
}
};
// ── Quick Kommentar modal ─────────────────────────────────────────
window.openRelNoteModal = function(caseId, caseTitle) {
_showRelModal(
`<i class="bi bi-chat-left-text me-2"></i>Kommentar`,
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} ${esc(caseTitle)}</label></div>
<textarea id="rqn_text" class="form-control" rows="4" placeholder="Skriv kommentar..."></textarea>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelNote(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
);
};
window._submitRelNote = async function(caseId) {
const text = document.getElementById('rqn_text').value.trim();
if (!text) return;
const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ forfatter: 'Hurtig kommentar', indhold: text })
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Kommentar tilføjet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved gemning', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Opgave modal ────────────────────────────────────────────
window.openRelTodoModal = function(caseId, caseTitle) {
const today = new Date().toISOString().split('T')[0];
_showRelModal(
`<i class="bi bi-check2-square me-2"></i>Opgave`,
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2"><label class="form-label small fw-semibold">Opgavetitel</label>
<input type="text" id="rqtd_title" class="form-control form-control-sm" placeholder="Hvad skal gøres?"></div>
<div class="mb-2"><label class="form-label small fw-semibold">Frist (valgfri)</label>
<input type="date" id="rqtd_due" class="form-control form-control-sm" value="${today}"></div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelTodo(${caseId})"><i class="bi bi-check2 me-1"></i>Opret</button>`
);
};
window._submitRelTodo = async function(caseId) {
const title = document.getElementById('rqtd_title').value.trim();
if (!title) { if (typeof showNotification === 'function') showNotification('Angiv opgavetitel', 'warning'); return; }
const due = document.getElementById('rqtd_due').value || null;
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}/todos`, {
method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ titel: title, frist: due, sag_id: caseId })
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Opgave-endpoint ikke tilgængeligt endnu', 'warning');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Tildel sag modal ────────────────────────────────────────
window.openRelAssignModal = async function(caseId, caseTitle) {
_showRelModal(
`<i class="bi bi-person-check me-2"></i>Tildel sag`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} ${esc(caseTitle)}</label></div>
<label class="form-label small fw-semibold">Ansvarlig bruger</label>
<select id="rqa_user" class="form-select form-select-sm"><option>Henter brugere</option></select>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelAssign(${caseId})"><i class="bi bi-check2 me-1"></i>Tildel</button>`
);
try {
const r = await fetch('/api/v1/users', { credentials: 'include' });
if (r.ok) {
const users = await r.json();
const sel = document.getElementById('rqa_user');
if (sel) sel.innerHTML = '<option value="">Ingen (fjern tildeling)</option>'
+ users.map(u => `<option value="${u.user_id}">${esc(u.display_name || u.username || '')}</option>`).join('');
}
} catch {}
};
window._submitRelAssign = async function(caseId) {
const userId = document.getElementById('rqa_user')?.value;
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH', credentials: 'include',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ ansvarlig_bruger_id: userId ? parseInt(userId) : null })
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Sag tildelt ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved tildeling', 'error');
if (saveBtn) saveBtn.disabled = false;
}
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── Quick Reminder modal ──────────────────────────────────────────
window.openRelReminderModal = function(caseId, caseTitle) {
const tmr = new Date(); tmr.setDate(tmr.getDate()+1);
const tmrStr = tmr.toISOString().slice(0,16);
_showRelModal(
`<i class="bi bi-bell me-2"></i>Påmindelse`,
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} ${esc(caseTitle)}</label></div>
<div class="mb-2"><label class="form-label small fw-semibold">Tidspunkt</label>
<input type="datetime-local" id="rqr_at" class="form-control form-control-sm" value="${tmrStr}"></div>
<div class="mb-2"><label class="form-label small fw-semibold">Besked</label>
<input type="text" id="rqr_msg" class="form-control form-control-sm" placeholder="Husk at…"></div>`,
`<button class="btn btn-sm btn-primary" onclick="_submitRelReminder(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
);
};
window._submitRelReminder = async function(caseId) {
const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value };
const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch('/api/v1/reminders', {
method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (r.ok) {
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Påmindelse oprettet', 'success');
} else { if (saveBtn) saveBtn.disabled = false; }
} catch { if (saveBtn) saveBtn.disabled = false; }
};
// ── shared modal helper ───────────────────────────────────────────
window._showRelModal = function(title, bodyHtml, footerBtns) {
let el = document.getElementById('relQaModalEl');
if (!el) {
el = document.createElement('div');
el.id = 'relQaModalEl';
el.className = 'modal fade';
el.tabIndex = -1;
el.innerHTML = `<div class="modal-dialog modal-dialog-centered"><div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title mb-0" id="relQaModalTitle"></h6>
<button type="button" class="btn-close btn-sm" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="relQaModalBody"></div>
<div class="modal-footer py-2 px-3" id="relQaModalFooter">
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
</div>
</div></div>`;
document.body.appendChild(el);
}
document.getElementById('relQaModalTitle').innerHTML = title;
document.getElementById('relQaModalBody').innerHTML = bodyHtml;
const footer = document.getElementById('relQaModalFooter');
// Remove old action buttons (keep Annuller)
footer.querySelectorAll('.btn-primary').forEach(b => b.remove());
if (footerBtns) footer.insertAdjacentHTML('afterbegin', footerBtns);
new bootstrap.Modal(el).show();
};
// ── init on page load ─────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', loadAllRelationTags);
})();

186
script_11.js Normal file
View File

@ -0,0 +1,186 @@
(function () {
const SAG_ID = {{ case.id }};
let _historyLoaded = false;
window.rewriteCaseDescriptionWithApproval = async function () {
const ta = document.getElementById('beskrivelse-textarea');
const rewriteBtn = document.getElementById('beskrivelse-rewrite-btn');
if (!ta) return;
const source = (ta.value || '').trim();
if (!source) {
if (typeof showNotification === 'function') showNotification('Skriv en beskrivelse først', 'warning');
else alert('Skriv en beskrivelse først');
return;
}
const originalHtml = rewriteBtn?.innerHTML || '';
if (rewriteBtn) {
rewriteBtn.disabled = true;
rewriteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const rewriteEndpoints = ['/api/v1/rewrite-text', '/api/v1/sag/rewrite-text', '/api/v1/emails/rewrite-text'];
let payload = null;
let lastError = null;
for (const endpoint of rewriteEndpoints) {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: source, context: 'case' })
});
if (response.ok) {
payload = await response.json();
lastError = null;
break;
}
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
if (err?.detail) detail = err.detail;
} catch (_) {}
lastError = new Error(detail);
// Retry next endpoint for common route mismatch cases.
if (![404, 405].includes(response.status)) {
break;
}
}
if (!payload) {
throw lastError || new Error('Kunne ikke hente renskrivningsforslag');
}
const rewrittenRaw = String(payload?.rewritten_text || '').trim();
const descMatch = rewrittenRaw.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
const rewritten = descMatch?.[1] ? descMatch[1].trim() : rewrittenRaw;
openRewriteReviewModal({
title: 'Sagsbeskrivelse',
originalText: source,
rewrittenText: rewritten,
applyToTarget: (nextText) => {
ta.value = nextText;
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
}
});
} catch (e) {
console.error(e);
if (typeof showNotification === 'function') showNotification('Kunne ikke renskrive beskrivelse', 'error');
else alert(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`);
} finally {
if (rewriteBtn) {
rewriteBtn.disabled = false;
rewriteBtn.innerHTML = originalHtml;
}
}
};
window.startBeskrivelsEdit = function () {
const current = document.getElementById('beskrivelse-text').innerText.trim();
document.getElementById('beskrivelse-textarea').value = current;
document.getElementById('beskrivelse-view').classList.add('d-none');
document.getElementById('beskrivelse-edit-btn')?.classList.add('d-none');
document.getElementById('beskrivelse-editor').classList.remove('d-none');
document.getElementById('beskrivelse-textarea').focus();
};
window.cancelBeskrivelsEdit = function () {
document.getElementById('beskrivelse-editor').classList.add('d-none');
document.getElementById('beskrivelse-view').classList.remove('d-none');
document.getElementById('beskrivelse-edit-btn')?.classList.remove('d-none');
};
window.saveBeskrivelsEdit = async function () {
const ta = document.getElementById('beskrivelse-textarea');
const saveBtn = document.getElementById('beskrivelse-save-btn');
const newVal = ta.value;
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Gemmer...'; }
try {
const res = await fetch(`/api/v1/sag/${SAG_ID}/beskrivelse`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beskrivelse: newVal })
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
// Update view
const textEl = document.getElementById('beskrivelse-text');
textEl.innerText = data.beskrivelse || '';
const emptyEl = document.getElementById('beskrivelse-empty');
if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : '';
cancelBeskrivelsEdit();
// Show history and mark stale
document.getElementById('beskrivelse-history-wrap').classList.remove('d-none');
_historyLoaded = false;
if (typeof showNotification === 'function') showNotification('Beskrivelse gemt', 'success');
} catch (e) {
console.error(e);
if (typeof showNotification === 'function') showNotification('Kunne ikke gemme beskrivelse', 'error');
} finally {
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check2 me-1"></i>Gem'; }
}
};
window.loadBeskrivelsHistory = async function () {
if (_historyLoaded) return;
const list = document.getElementById('beskrivelse-history-list');
try {
const res = await fetch(`/api/v1/sag/${SAG_ID}/beskrivelse/history`, { credentials: 'include' });
if (!res.ok) throw new Error('failed');
const rows = await res.json();
_historyLoaded = true;
const label = document.getElementById('beskrivelse-history-label');
if (!rows.length) {
label.textContent = 'Historik (0)';
list.innerHTML = '<div class="list-group-item text-muted text-center py-2 small">Ingen historik endnu.</div>';
return;
}
label.textContent = `Historik (${rows.length})`;
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const trunc = (s, n) => s && s.length > n ? s.substring(0, n) + '…' : (s || '');
list.innerHTML = rows.map(h => {
const d = new Date(h.changed_at);
const when = d.toLocaleDateString('da-DK', {day:'2-digit',month:'2-digit',year:'numeric'})
+ ' ' + d.toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'});
const who = esc(h.changed_by_name || 'Ukendt');
const before = h.beskrivelse_before ? esc(trunc(h.beskrivelse_before, 150)) : '<em class="text-muted">tom</em>';
const after = h.beskrivelse_after ? esc(trunc(h.beskrivelse_after, 150)) : '<em class="text-muted">tom</em>';
return `<div class="list-group-item px-3 py-2">
<div class="d-flex justify-content-between mb-1">
<span class="fw-semibold small">${who}</span>
<span class="text-muted small">${when}</span>
</div>
<div class="d-flex gap-3" style="font-size:.85rem">
<div style="flex:1"><span class="badge text-bg-danger me-1" style="font-size:.7rem">Før</span>${before}</div>
<div style="flex:1"><span class="badge text-bg-success me-1" style="font-size:.7rem">Efter</span>${after}</div>
</div>
</div>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="list-group-item text-muted text-center py-2 small">Kunne ikke indlæse historik.</div>';
}
};
// Keyboard shortcuts
document.addEventListener('keydown', function (e) {
const editor = document.getElementById('beskrivelse-editor');
if (!editor || editor.classList.contains('d-none')) return;
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); saveBeskrivelsEdit(); }
if (e.key === 'Escape') { e.preventDefault(); cancelBeskrivelsEdit(); }
});
// Show history toggle if description already exists on page load
if ((document.getElementById('beskrivelse-text').innerText || '').trim()) {
document.getElementById('beskrivelse-history-wrap').classList.remove('d-none');
}
})();

578
script_2.js Normal file
View File

@ -0,0 +1,578 @@
function _escapeCommentHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _removeQuotedMailLines(text) {
const source = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = source.split('\n');
const kept = [];
const headerRe = /^(fra|from|sent|date|dato|to|til|emne|subject|cc):\s*/i;
const originalMessageRe = /^(original message|oprindelig besked|videresendt besked)/i;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed.startsWith('>')) break;
if (originalMessageRe.test(trimmed)) break;
if (/^[-_]{3,}$/.test(trimmed)) {
const lookahead = lines.slice(i + 1, i + 4);
if (lookahead.some((candidate) => headerRe.test(String(candidate || '').trim()))) {
break;
}
}
if (i > 0 && headerRe.test(trimmed) && String(lines[i - 1] || '').trim() === '') {
break;
}
kept.push(line);
}
while (kept.length > 0 && String(kept[kept.length - 1] || '').trim() === '') {
kept.pop();
}
return kept.join('\n').trim();
}
function _parseEmailComment(rawText) {
const normalized = String(rawText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const emailIdMatch = normalized.match(/^Email-ID:\s*(\d+)\s*$/m);
const emailId = emailIdMatch ? Number(emailIdMatch[1]) : null;
const withoutMeta = normalized.replace(/^Email-ID:\s*\d+\s*\n?/m, '').trim();
return {
emailId,
visibleText: _removeQuotedMailLines(withoutMeta)
};
}
function _formatEmailHeaderTimestamp(value) {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return String(value);
return parsed.toLocaleString('da-DK');
}
function _buildEmailHeaderAndBody(visibleText) {
const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
const lines = text.split('\n');
let idx = 0;
let typeLabel = 'Indgaaende email';
const firstLine = String(lines[0] || '').trim();
if (/^📧\s*Indgående email/i.test(firstLine)) {
typeLabel = 'Indgaaende email';
idx = 1;
} else if (/^📧\s*Udgående email/i.test(firstLine)) {
typeLabel = 'Udgaaende email';
idx = 1;
}
let fra = '';
let til = '';
let cc = '';
let emne = '';
let modtaget = '';
while (idx < lines.length) {
const line = String(lines[idx] || '').trim();
if (!line) {
idx += 1;
break;
}
if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim();
else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim();
else if (/^Cc:\s*/i.test(line)) cc = line.replace(/^Cc:\s*/i, '').trim();
else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim();
else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim();
else break;
idx += 1;
}
const bodyText = lines.slice(idx).join('\n').trim();
const summaryParts = [typeLabel];
if (fra) summaryParts.push(`Fra: ${fra}`);
if (til) summaryParts.push(`Til: ${til}`);
if (cc) summaryParts.push(`Cc: ${cc}`);
if (emne) summaryParts.push(`Emne: ${emne}`);
if (modtaget) summaryParts.push(`Modtaget: ${_formatEmailHeaderTimestamp(modtaget)}`);
return {
summary: summaryParts.join(' • '),
bodyText
};
}
function _extractEmailHeaderFields(visibleText) {
const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
const lines = text.split('\n');
let idx = 0;
const firstLine = String(lines[0] || '').trim();
const isOutgoing = /^📧\s*Udgående email/i.test(firstLine);
if (/^📧\s*(Indgående|Udgående)\s+email/i.test(firstLine)) {
idx = 1;
}
let fra = '';
let til = '';
let emne = '';
let modtaget = '';
while (idx < lines.length) {
const line = String(lines[idx] || '').trim();
if (!line) break;
if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim();
else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim();
else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim();
else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim();
else break;
idx += 1;
}
return { fra, til, emne, modtaget, isOutgoing };
}
function _normalizeReplySubject(value) {
const subject = String(value || '').trim();
return subject.replace(/^(re|fw|fwd)\s*:\s*/ig, '').toLowerCase();
}
function _findBestLinkedEmailByHeader(header) {
const targetSubject = _normalizeReplySubject(header?.emne || '');
const targetFrom = String(header?.fra || '').trim().toLowerCase();
const targetTo = String(header?.til || '').trim().toLowerCase();
const candidates = (linkedEmailsCache || []).filter((email) => {
const emailSubject = _normalizeReplySubject(email?.subject || '');
if (targetSubject && emailSubject !== targetSubject) {
return false;
}
const sender = String(email?.sender_email || email?.sender_name || '').toLowerCase();
const recipient = String(email?.recipient_email || '').toLowerCase();
if (targetFrom && sender && sender.includes(targetFrom)) {
return true;
}
if (targetTo && recipient && recipient.includes(targetTo)) {
return true;
}
return !targetFrom && !targetTo;
});
if (!candidates.length) {
return null;
}
candidates.sort((a, b) => {
const aTs = a?.received_date ? new Date(a.received_date).getTime() : 0;
const bTs = b?.received_date ? new Date(b.received_date).getTime() : 0;
return bTs - aTs;
});
return Number(candidates[0]?.id) || null;
}
function _extractEmailAddress(value) {
const raw = String(value || '').trim();
const match = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return match ? match[0] : raw;
}
function _commentInitials(name) {
const clean = String(name || '').trim();
if (!clean) return 'EM';
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
}
function _formatCommentTime(value) {
const parsed = new Date(value || Date.now());
if (Number.isNaN(parsed.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${pad(parsed.getDate())}/${pad(parsed.getMonth() + 1)}-${parsed.getFullYear()} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
}
function _refreshCommentCountBadge() {
const container = document.getElementById('comments-container');
const badge = document.querySelector('#beskrivelse-comments-wrap .badge.bg-secondary');
if (!container || !badge) return;
badge.textContent = String(container.querySelectorAll('.comment-item').length);
}
function prependCommentToThread(comment) {
const container = document.getElementById('comments-container');
if (!container || !comment || !comment.indhold) return;
const emptyState = container.querySelector('p.text-center.text-muted.my-3');
if (emptyState) emptyState.remove();
const author = String(comment.forfatter || 'Email Bot');
const createdAtIso = String(comment.created_at || new Date().toISOString());
const createdAtMs = new Date(createdAtIso).getTime();
const createdAtUnix = Number.isFinite(createdAtMs) ? Math.floor(createdAtMs / 1000) : Math.floor(Date.now() / 1000);
const item = document.createElement('div');
item.className = 'comment-item comment-system';
item.dataset.createdAt = String(createdAtUnix);
const meta = document.createElement('div');
meta.className = 'comment-meta';
meta.innerHTML = `
<span class="comment-avatar">${_escapeCommentHtml(_commentInitials(author))}</span>
<b>${_escapeCommentHtml(author)}</b>
<span class="comment-time">${_escapeCommentHtml(_formatCommentTime(createdAtIso))}</span>
`;
const body = document.createElement('div');
body.className = 'comment-body';
body.setAttribute('data-comment-raw', String(comment.indhold));
body.textContent = String(comment.indhold);
item.appendChild(meta);
item.appendChild(body);
container.insertBefore(item, container.firstChild);
processCommentBodies();
sortCommentsNewestFirst();
_refreshCommentCountBadge();
}
let activeCommentQuickReply = null;
window.closeInlineCommentQuickReply = function() {
const host = document.getElementById('comment-quick-reply-host');
if (host) host.innerHTML = '';
activeCommentQuickReply = null;
}
window.sendInlineCommentQuickReply = async function() {
const host = document.getElementById('comment-quick-reply-host');
const textarea = document.getElementById('commentQuickReplyText');
const sendBtn = document.getElementById('commentQuickReplySendBtn');
const statusEl = document.getElementById('commentQuickReplyStatus');
if (!host || !textarea || !sendBtn || !statusEl || !activeCommentQuickReply) return;
const bodyText = String(textarea.value || '').trim();
if (!bodyText) {
statusEl.className = 'comment-quick-reply-status text-danger';
statusEl.textContent = 'Skriv et svar';
return;
}
const recipient = _extractEmailAddress(activeCommentQuickReply.recipient);
if (!recipient || recipient.indexOf('@') === -1) {
statusEl.className = 'comment-quick-reply-status text-danger';
statusEl.textContent = 'Ingen gyldig modtager fundet i kommentaren';
return;
}
sendBtn.disabled = true;
statusEl.className = 'comment-quick-reply-status';
statusEl.textContent = 'Sender...';
try {
await loadLinkedEmails();
let threadEmailId = Number(activeCommentQuickReply.emailId) || null;
if (!threadEmailId) {
threadEmailId = _findBestLinkedEmailByHeader(activeCommentQuickReply.header);
}
let threadKey = null;
if (threadEmailId) {
const linked = linkedEmailsCache.find((entry) => Number(entry.id) === Number(threadEmailId));
threadKey = linked?.thread_key || linked?.resolved_thread_key || null;
}
const response = await fetch(`/api/v1/sag/${caseIds}/emails/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: [recipient],
subject: activeCommentQuickReply.subject,
body_text: bodyText,
thread_email_id: threadEmailId,
thread_key: threadKey
})
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const payload = await response.json();
message = payload?.detail || payload?.message || message;
} catch (_) {
}
throw new Error(message);
}
const result = await response.json();
if (result?.comment) {
prependCommentToThread(result.comment);
}
statusEl.className = 'comment-quick-reply-status text-success';
statusEl.textContent = 'Svar sendt';
textarea.value = '';
await loadLinkedEmails();
setTimeout(() => {
window.closeInlineCommentQuickReply();
}, 500);
} catch (error) {
statusEl.className = 'comment-quick-reply-status text-danger';
statusEl.textContent = error?.message || 'Kunne ikke sende svar';
} finally {
sendBtn.disabled = false;
}
}
function openInlineCommentQuickReply(rawText, emailId) {
const host = document.getElementById('comment-quick-reply-host');
if (!host) return;
const parsed = _parseEmailComment(rawText || '');
const header = _extractEmailHeaderFields(parsed.visibleText || '');
const fallbackRecipient = header.isOutgoing ? (header.til || header.fra) : (header.fra || header.til);
const subject = /^re:\s*/i.test(header.emne || '')
? (header.emne || `Sag #${caseIds}`)
: `Re: ${header.emne || `Sag #${caseIds}`}`;
activeCommentQuickReply = {
rawText,
header,
emailId: Number(emailId) || parsed.emailId || null,
recipient: fallbackRecipient,
subject
};
host.innerHTML = `
<div class="comment-quick-reply-box">
<div class="small text-muted mb-1">Quick svar til ${_escapeCommentHtml(String(fallbackRecipient || 'ukendt modtager'))}</div>
<textarea id="commentQuickReplyText" class="form-control" rows="2" placeholder="Skriv hurtigt svar..."></textarea>
<div class="comment-quick-reply-actions">
<div id="commentQuickReplyStatus" class="comment-quick-reply-status"></div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="closeInlineCommentQuickReply()">Annuller</button>
<button type="button" class="btn btn-sm btn-primary" id="commentQuickReplySendBtn" onclick="sendInlineCommentQuickReply()"><i class="bi bi-send me-1"></i>Send</button>
</div>
</div>
</div>
`;
const textarea = document.getElementById('commentQuickReplyText');
if (textarea) {
textarea.focus();
}
}
async function quickReplyToEmailFromCommentText(rawText) {
openCaseEmailTab();
const parsed = _parseEmailComment(rawText || '');
const header = _extractEmailHeaderFields(parsed.visibleText || '');
try {
await loadLinkedEmails();
const matchedEmailId = _findBestLinkedEmailByHeader(header);
if (matchedEmailId) {
await loadLinkedEmailDetail(matchedEmailId);
openReplyToLinkedEmail();
return;
}
} catch (error) {
console.error('Kunne ikke finde trådmail fra kommentar:', error);
}
const composeModalEl = document.getElementById('caseEmailComposeModal');
if (!composeModalEl) return;
const toInput = document.getElementById('caseEmailTo');
const subjectInput = document.getElementById('caseEmailSubject');
const bodyInput = document.getElementById('caseEmailBody');
const fallbackRecipient = (header.isOutgoing ? header.til : header.fra) || header.fra || header.til || '';
if (toInput && !toInput.value.trim() && fallbackRecipient) {
toInput.value = fallbackRecipient;
}
if (subjectInput && !subjectInput.value.trim()) {
subjectInput.value = escapeHtmlForInput(
/^re:\s*/i.test(header.emne || '')
? (header.emne || `Sag #${caseIds}`)
: `Re: ${header.emne || `Sag #${caseIds}`}`
);
}
if (bodyInput && !bodyInput.value.trim()) {
bodyInput.value = `\n\n---\nFra: ${header.fra || '-'}\nDato: ${header.modtaget || '-'}\nEmne: ${header.emne || '(Ingen emne)'}\n`;
}
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
}
async function openEmailFromComment(emailId) {
const parsedId = Number(emailId);
if (!Number.isFinite(parsedId)) return;
if (typeof openCaseEmailTab === 'function') {
openCaseEmailTab();
}
try {
if (typeof loadLinkedEmails === 'function') {
await loadLinkedEmails();
}
if (typeof loadLinkedEmailDetail === 'function') {
await loadLinkedEmailDetail(parsedId);
}
const emailTabPane = document.getElementById('emails');
if (emailTabPane) {
emailTabPane.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} catch (error) {
console.error('Kunne ikke åbne email fra kommentar:', error);
}
}
function processCommentBodies() {
const commentItems = Array.from(document.querySelectorAll('#comments-container .comment-item'));
commentItems.forEach((item) => {
const body = item.querySelector('.comment-body');
if (!body) return;
const rawText = body.dataset.commentRaw || body.textContent || '';
if (!item.classList.contains('comment-system')) {
body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '<br>');
return;
}
const hasEmailHeader = /(^|\n)\s*📧\s*(Indgående|Udgående)\s+email/i.test(String(rawText));
if (!hasEmailHeader) {
body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '<br>');
return;
}
const parsed = _parseEmailComment(rawText);
const display = _buildEmailHeaderAndBody(parsed.visibleText || '');
const safeHeader = _escapeCommentHtml(display.summary || 'Indgaaende email');
const safeBody = _escapeCommentHtml(display.bodyText || '').replace(/\n/g, '<br>');
body.innerHTML = `
<div class="comment-email-header" title="${safeHeader}">${safeHeader}</div>
${display.bodyText ? `<div class="comment-email-text">${safeBody}</div>` : ''}
`;
const existingActions = item.querySelector('.comment-actions');
if (existingActions) {
existingActions.remove();
}
if (parsed.emailId) {
const actions = document.createElement('div');
actions.className = 'comment-actions';
actions.innerHTML = `
<button type="button" class="btn btn-link btn-sm" onclick="openEmailFromComment(${parsed.emailId})"><i class="bi bi-envelope-open me-1"></i>Aabn fuld mail</button>
<button type="button" class="btn btn-link btn-sm" onclick="quickReplyToEmailFromComment(${parsed.emailId})"><i class="bi bi-reply me-1"></i>Svar</button>
<button type="button" class="btn btn-link btn-sm js-quick-inline-reply"><i class="bi bi-lightning-charge me-1"></i>Quick svar</button>
`;
item.appendChild(actions);
const quickInlineBtn = actions.querySelector('.js-quick-inline-reply');
if (quickInlineBtn) {
quickInlineBtn.addEventListener('click', () => {
openInlineCommentQuickReply(rawText, parsed.emailId);
});
}
} else {
const actions = document.createElement('div');
actions.className = 'comment-actions';
actions.innerHTML = `
<button type="button" class="btn btn-link btn-sm" onclick="openCaseEmailTab()"><i class="bi bi-envelope me-1"></i>Aabn email-fane</button>
<button type="button" class="btn btn-link btn-sm js-reply-fallback"><i class="bi bi-reply me-1"></i>Svar</button>
<button type="button" class="btn btn-link btn-sm js-quick-reply-fallback"><i class="bi bi-lightning-charge me-1"></i>Quick svar</button>
`;
item.appendChild(actions);
const replyBtn = actions.querySelector('.js-reply-fallback');
if (replyBtn) {
replyBtn.addEventListener('click', () => {
quickReplyToEmailFromCommentText(rawText);
});
}
const quickReplyBtn = actions.querySelector('.js-quick-reply-fallback');
if (quickReplyBtn) {
quickReplyBtn.addEventListener('click', () => {
openInlineCommentQuickReply(rawText, null);
});
}
}
});
}
function sortCommentsNewestFirst() {
const container = document.getElementById('comments-container');
if (!container) return;
const items = Array.from(container.querySelectorAll('.comment-item'));
if (items.length < 2) return;
items
.sort((a, b) => Number(b.dataset.createdAt || 0) - Number(a.dataset.createdAt || 0))
.forEach((item) => container.appendChild(item));
}
async function submitComment(event) {
event.preventDefault();
const form = event.target;
const content = form.indhold.value;
const btn = form.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Sender...';
btn.disabled = true;
try {
const response = await fetch('/api/v1/sag/{{ case.id }}/kommentarer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
indhold: content
})
});
if (response.ok) {
location.reload();
} else {
alert('Fejl ved oprettelse af kommentar');
btn.innerHTML = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Der skete en fejl. Prøv igen.');
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// Keep newest comments visible at top
document.addEventListener('DOMContentLoaded', function() {
sortCommentsNewestFirst();
processCommentBodies();
const container = document.getElementById('comments-container');
if(container) {
container.scrollTop = 0;
}
});

208
script_3.js Normal file
View File

@ -0,0 +1,208 @@
const salesCaseId = {{ case.id }};
function formatCurrency(value) {
const num = Number(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num);
}
function formatNumber(value) {
const num = Number(value || 0);
return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num);
}
let saleItemsCache = [];
async function loadVarekobSalg() {
try {
const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`);
if (!res.ok) throw new Error('Failed to load aggregated data');
const data = await res.json();
document.getElementById('salesTotalPurchase').textContent = formatCurrency(data?.totals?.purchase_total);
document.getElementById('salesTotalSale').textContent = formatCurrency(data?.totals?.sale_total);
document.getElementById('salesTotalNet').textContent = formatCurrency(data?.totals?.net_total);
document.getElementById('salesTotalHours').textContent = formatNumber(data?.totals?.total_hours) + ' t';
document.getElementById('salesBillableHours').textContent = formatNumber(data?.totals?.billable_hours) + ' t';
saleItemsCache = data.sale_items || [];
renderSaleItems(saleItemsCache);
renderTimeEntries(data.time_entries || []);
const hasSalesData = (data.sale_items || []).length > 0 || (data.time_entries || []).length > 0;
setModuleContentState('sales', hasSalesData);
} catch (error) {
console.error(error);
const saleBody = document.getElementById('saleItemsBody');
if (saleBody) {
saleBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
}
const timeBody = document.getElementById('salesTimeBody');
if (timeBody) {
timeBody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
}
setModuleContentState('sales', true);
}
}
function renderSaleItems(items) {
const salesBody = document.getElementById('saleItemsSalesBody');
const purchaseBody = document.getElementById('saleItemsPurchaseBody');
const salesSubtotal = document.getElementById('salesLinesSubtotal');
const purchaseSubtotal = document.getElementById('purchaseLinesSubtotal');
if (!salesBody || !purchaseBody) return;
const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase');
const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase');
const renderRows = (list) => {
if (!list.length) {
return '<tr><td colspan="9" class="text-center py-4 text-muted">Ingen linjer</td></tr>';
}
return list.map(item => {
const statusLabel = item.status || 'draft';
const isSubcase = item.sag_id && item.sag_id !== salesCaseId;
const sourceBadge = isSubcase
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
return `
<tr>
<td class="ps-4">${item.line_date || '-'}</td>
<td>${item.description || '-'}</td>
<td>${item.quantity ?? '-'}</td>
<td>${item.unit || '-'}</td>
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
<td class="fw-bold">${formatCurrency(item.amount)}</td>
<td>${item.source_sag_titel || '-'}${sourceBadge}</td>
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" onclick='openSaleItemModalById(${item.id})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger" onclick='deleteSaleItem(${item.id})'><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`;
}).join('');
};
salesBody.innerHTML = renderRows(salesItems);
purchaseBody.innerHTML = renderRows(purchaseItems);
const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
if (salesSubtotal) salesSubtotal.textContent = formatCurrency(salesSum);
if (purchaseSubtotal) purchaseSubtotal.textContent = formatCurrency(purchaseSum);
}
function renderTimeEntries(entries) {
const tbody = document.getElementById('salesTimeBody');
if (!tbody) return;
if (!entries.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Ingen tid registreret</td></tr>';
return;
}
tbody.innerHTML = entries.map(entry => {
const hours = entry.approved_hours || entry.original_hours || 0;
const isSubcase = entry.sag_id && entry.sag_id !== salesCaseId;
const sourceBadge = isSubcase
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
return `
<tr>
<td class="ps-3">${entry.worked_date || '-'}</td>
<td>${formatNumber(hours)} t</td>
<td>${entry.source_sag_titel || '-'}${sourceBadge}</td>
</tr>
`;
}).join('');
}
function openSaleItemModal(item = null) {
document.getElementById('sale_item_id').value = item?.id || '';
document.getElementById('sale_type').value = item?.type || 'sale';
document.getElementById('sale_status').value = item?.status || 'draft';
document.getElementById('sale_date').value = item?.line_date || '';
document.getElementById('sale_description').value = item?.description || '';
document.getElementById('sale_quantity').value = item?.quantity ?? '';
document.getElementById('sale_unit').value = item?.unit || '';
document.getElementById('sale_unit_price').value = item?.unit_price ?? '';
document.getElementById('sale_amount').value = item?.amount ?? '';
document.getElementById('sale_currency').value = item?.currency || 'DKK';
document.getElementById('sale_external_ref').value = item?.external_ref || '';
new bootstrap.Modal(document.getElementById('saleItemModal')).show();
}
function openSaleItemModalById(itemId) {
const item = saleItemsCache.find((entry) => entry.id === itemId);
openSaleItemModal(item || null);
}
function updateSaleAmount() {
const qty = parseFloat(document.getElementById('sale_quantity').value || 0);
const price = parseFloat(document.getElementById('sale_unit_price').value || 0);
if (qty && price) {
document.getElementById('sale_amount').value = (qty * price).toFixed(2);
}
}
async function saveSaleItem() {
const itemId = document.getElementById('sale_item_id').value;
const payload = {
type: document.getElementById('sale_type').value,
status: document.getElementById('sale_status').value,
line_date: document.getElementById('sale_date').value || null,
description: document.getElementById('sale_description').value,
quantity: document.getElementById('sale_quantity').value || null,
unit: document.getElementById('sale_unit').value || null,
unit_price: document.getElementById('sale_unit_price').value || null,
amount: document.getElementById('sale_amount').value,
currency: document.getElementById('sale_currency').value || 'DKK',
external_ref: document.getElementById('sale_external_ref').value || null
};
if (!payload.description || !payload.amount) {
alert('Beskrivelse og linjesum er påkrævet.');
return;
}
const method = itemId ? 'PATCH' : 'POST';
const url = itemId
? `/api/v1/sag/${salesCaseId}/sale-items/${itemId}`
: `/api/v1/sag/${salesCaseId}/sale-items`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
alert('Kunne ikke gemme varelinje');
return;
}
bootstrap.Modal.getInstance(document.getElementById('saleItemModal')).hide();
await loadVarekobSalg();
}
async function deleteSaleItem(itemId) {
if (!confirm('Vil du slette denne varelinje?')) return;
const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items/${itemId}`, { method: 'DELETE' });
if (!res.ok) {
alert('Kunne ikke slette varelinje');
return;
}
await loadVarekobSalg();
}
document.addEventListener('DOMContentLoaded', function() {
const qtyInput = document.getElementById('sale_quantity');
const priceInput = document.getElementById('sale_unit_price');
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
loadVarekobSalg();
});

356
script_4.js Normal file
View File

@ -0,0 +1,356 @@
const timeCaseId = {{ case.id }};
function minutesToLabel(minutes) {
const value = Number(minutes || 0);
const h = Math.floor(value / 60);
const m = value % 60;
return `${h}t ${m}m`;
}
function timeStatusBadge(status) {
if (status === 'godkendt') return '<span class="badge bg-success">Godkendt</span>';
if (status === 'kladde') return '<span class="badge bg-secondary">Kladde</span>';
return '<span class="badge bg-warning text-dark">Afventer</span>';
}
function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
if (!timeline) return;
if (!entries || entries.length === 0) {
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
return;
}
const START_HOUR = 7;
const TOTAL_HOURS = 10; // 07:00 to 17:00
const HOUR_HEIGHT = 60; // px
const groupedByDate = {};
entries.forEach((entry) => {
let dateKey = 'Ukendt dato';
if (entry.start_tid) {
dateKey = entry.start_tid.split('T')[0];
} else if (entry.worked_date) {
dateKey = entry.worked_date;
} else if (entry.created_at) {
dateKey = entry.created_at.split('T')[0];
}
// Keep only first 10 chars for proper grouping if it's an ISO timestamp
if (dateKey.length > 10) dateKey = dateKey.substring(0, 10);
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
groupedByDate[dateKey].push(entry);
});
const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a));
let html = '';
sortedDates.forEach(dateStr => {
const dayEntries = groupedByDate[dateStr];
let formattedDateLab = dateStr;
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1);
}
} catch(e){}
const techs = {};
const unplaced = [];
dayEntries.forEach(entry => {
const tech = entry.bruger_navn || entry.user_name || 'Ukendt';
if (!techs[tech]) techs[tech] = [];
if (!entry.start_tid || entry.start_tid === null) {
unplaced.push(entry);
} else {
techs[tech].push(entry);
}
});
const techNames = Object.keys(techs).sort();
html += `
<div class="time-v1-calendar-container">
<div class="time-v1-calendar-header">
<i class="bi bi-calendar3 text-primary"></i> ${formattedDateLab}
</div>
<div class="time-v1-calendar-grid">
<div class="time-v1-time-axis">
`;
for (let i = 0; i <= TOTAL_HOURS; i++) {
const h = START_HOUR + i;
const top = i * HOUR_HEIGHT;
html += `<div class="time-v1-hour-marker" style="top: ${top}px">${h.toString().padStart(2, '0')}:00</div>`;
}
html += `</div>`;
techNames.forEach(tech => {
html += `
<div class="time-v1-tech-col" data-tech="${escapeHtml(tech)}" data-date="${dateStr}">
<div class="time-v1-tech-header">
<i class="bi bi-person-circle text-secondary"></i> ${escapeHtml(tech)}
</div>
<div class="time-v1-tech-body">
`;
techs[tech].forEach(entry => {
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
const status = entry.entry_status || entry.status || 'kladde';
let cssClass = 'time-v1-entry-kladde';
if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
const startObj = new Date(entry.start_tid);
let durationMin = 30; // default length
if (entry.faktisk_tid_min) {
durationMin = parseInt(entry.faktisk_tid_min);
} else if (entry.original_hours || entry.timer) {
durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
}
let startH = startObj.getHours();
let startM = startObj.getMinutes();
if (startH < START_HOUR) {
durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
startH = START_HOUR;
startM = 0;
}
let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
let heightPx = (durationMin / 60) * HOUR_HEIGHT;
if (topPx < 0) topPx = 0;
if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
}
if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
const endObj = new Date(startObj.getTime() + durationMin * 60000);
const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
html += `
<div class="time-v1-entry-block ${cssClass}" style="top: ${topPx}px; height: ${heightPx}px;" title="${desc}">
<div class="time-v1-entry-time">${timeStr}</div>
<div class="time-v1-entry-desc text-wrap">${desc}</div>
</div>
`;
}
});
html += `
</div>
</div>
`;
});
html += `</div>`;
if (unplaced.length > 0) {
html += `<div class="time-v1-unplaced-container">
<span class="text-muted small fw-semibold"><i class="bi bi-clock-history"></i> Uden tidsrum:</span>
`;
unplaced.forEach(u => {
const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
const hrs = u.original_hours || u.timer || 0;
html += `<div class="time-v1-unplaced-item">
<i class="bi bi-person text-secondary"></i> ${userName} &bull; ${hrs}t
</div>`;
});
html += `</div>`;
}
html += `</div>`;
});
timeline.innerHTML = html;
}
async function loadTimeTrackingTab() {
try {
const res = await fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}`);
if (!res.ok) throw new Error('Kunne ikke hente tidsforbrug');
const entries = await res.json();
renderTimeV1Timeline(entries || []);
setModuleContentState('timetracking', (entries || []).length > 0);
} catch (error) {
console.error(error);
const timeline = document.getElementById('timeTimelineColumns');
if (timeline) {
timeline.innerHTML = '<div class="text-danger text-center py-3">Kunne ikke hente tidsforbrug.</div>';
}
setModuleContentState('timetracking', true);
}
}
async function startLiveTimerV1() {
try {
const res = await fetch('/api/v1/timetracking/time/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: timeCaseId,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
beskrivelse: document.getElementById('timeV1Description')?.value || null
})
});
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke starte timer: ' + (error.message || 'ukendt fejl'));
}
}
async function stopLiveTimerV1(extra = {}) {
try {
const res = await fetch('/api/v1/timetracking/time/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(extra || {})
});
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke stoppe timer: ' + (error.message || 'ukendt fejl'));
}
}
function bindTimeV1Calculations() {
const startIn = document.getElementById('timeV1Start');
const endIn = document.getElementById('timeV1End');
const minIn = document.getElementById('timeV1Minutes');
if (!startIn || !endIn || !minIn) return;
const parseTime = (val) => {
if (!val) return null;
const [h,m] = val.split(':').map(Number);
return (h * 60) + m;
};
const toTimeStr = (totalMins) => {
const h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
const recalculate = (trigger) => {
const s = parseTime(startIn.value);
const e = parseTime(endIn.value);
const dur = parseInt(minIn.value);
if (trigger === 'start' || trigger === 'end') {
if (s !== null && e !== null) {
let diff = e - s;
if (diff < 0) diff += 24*60;
minIn.value = diff;
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while (base < 0) base += 24*60;
startIn.value = toTimeStr(base);
}
} else if (trigger === 'min') {
if (s !== null && !isNaN(dur) && dur > 0) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while(base < 0) base+=24*60;
startIn.value = toTimeStr(base);
}
}
};
startIn.addEventListener('change', () => recalculate('start'));
endIn.addEventListener('change', () => recalculate('end'));
minIn.addEventListener('input', () => recalculate('min'));
}
async function createManualTimeV1(event) {
event.preventDefault();
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
if (minutes <= 0) {
alert('Indtast minutter over 0');
return;
}
const dateVal = document.getElementById('timeV1Date')?.value || null;
const tStart = document.getElementById('timeV1Start')?.value;
const tEnd = document.getElementById('timeV1End')?.value;
let startObj = null;
let endObj = null;
if (dateVal && tStart) {
try {
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
}
if (dateVal && tEnd) {
try {
const l = new Date(`${dateVal}T${tEnd}:00`);
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
}
const payload = {
sag_id: timeCaseId,
medarbejder_id: getTimeV1EmployeeId(),
faktisk_tid_min: minutes,
worked_date: dateVal,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
entry_status: document.getElementById('timeV1Status')?.value || 'afventer',
beskrivelse: document.getElementById('timeV1Description')?.value || null,
kilde: 'manuel',
start_tid: startObj,
slut_tid: endObj
};
try {
const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const minutesInput = document.getElementById('timeV1Minutes');
const descInput = document.getElementById('timeV1Description');
const startIn = document.getElementById('timeV1Start');
const endIn = document.getElementById('timeV1End');
if (minutesInput) minutesInput.value = '';
if (descInput) descInput.value = '';
if (startIn) startIn.value = '';
if (endIn) endIn.value = '';
await loadTimeTrackingTab();
} catch (error) {
alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
}
}
document.addEventListener('DOMContentLoaded', () => {
bindTimeV1Calculations();
const dateInput = document.getElementById('timeV1Date');
if (dateInput && !dateInput.value) {
dateInput.valueAsDate = new Date();
}
});

344
script_5.js Normal file
View File

@ -0,0 +1,344 @@
let reminderUserId = null;
const remindersCaseId = {{ case.id }};
function getReminderUserId() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub || payload.user_id;
} catch (e) {
console.warn('Could not decode token for reminder user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) return metaTag.getAttribute('content');
return null;
}
async function ensureReminderUserId() {
const localId = getReminderUserId();
if (localId) return localId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
return me?.id || me?.user_id || null;
} catch (err) {
return null;
}
}
function formatReminderDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('da-DK', { hour12: false });
}
function updateReminderTriggerFields() {
const triggerType = document.getElementById('rem_trigger_type')?.value;
const timeWrap = document.getElementById('rem_trigger_time_wrap');
const statusWrap = document.getElementById('rem_trigger_status_wrap');
if (timeWrap && statusWrap) {
if (triggerType === 'status_change') {
timeWrap.classList.add('d-none');
statusWrap.classList.remove('d-none');
} else {
timeWrap.classList.remove('d-none');
statusWrap.classList.add('d-none');
}
}
}
function updateReminderRecurrenceFields() {
const recurrenceType = document.getElementById('rem_recurrence_type')?.value;
const dowWrap = document.getElementById('rem_recurrence_dow_wrap');
const domWrap = document.getElementById('rem_recurrence_dom_wrap');
if (!dowWrap || !domWrap) return;
dowWrap.classList.toggle('d-none', recurrenceType !== 'weekly');
domWrap.classList.toggle('d-none', recurrenceType !== 'monthly');
}
function openCreateReminderModal(defaultEventType) {
reminderUserId = getReminderUserId();
const warning = document.getElementById('rem_user_warning');
if (warning) warning.classList.toggle('d-none', !!reminderUserId);
const form = document.getElementById('createReminderForm');
if (form) form.reset();
document.getElementById('rem_notify_frontend').checked = true;
document.getElementById('rem_priority').value = 'normal';
document.getElementById('rem_event_type').value = defaultEventType || 'reminder';
document.getElementById('rem_trigger_type').value = 'time_based';
document.getElementById('rem_recurrence_type').value = 'once';
updateReminderTriggerFields();
updateReminderRecurrenceFields();
new bootstrap.Modal(document.getElementById('createReminderModal')).show();
}
async function loadReminders() {
const list = document.getElementById('remindersList');
if (!list) return;
reminderUserId = await ensureReminderUserId();
if (!reminderUserId) {
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke finde bruger-id.</div>';
setModuleContentState('reminders', true);
return;
}
list.innerHTML = '<div class="p-4 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter reminders...</div>';
try {
const res = await fetch(`/api/v1/sag/${remindersCaseId}/reminders?user_id=${reminderUserId}`);
if (!res.ok) throw new Error('Kunne ikke hente reminders');
const reminders = await res.json();
renderReminders(reminders);
} catch (e) {
console.error(e);
list.innerHTML = '<div class="p-4 text-center text-danger">Fejl ved hentning af reminders</div>';
setModuleContentState('reminders', true);
}
}
function renderReminders(reminders) {
const list = document.getElementById('remindersList');
if (!list) return;
if (!reminders || reminders.length === 0) {
list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders endnu.</div>';
setModuleContentState('reminders', false);
return;
}
const triggerLabels = {
time_based: 'Tidspunkt',
status_change: 'Status ændring',
deadline_approaching: 'Deadline'
};
const eventTypeLabels = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline'
};
const recurrenceLabels = {
once: 'Én gang',
daily: 'Dagligt',
weekly: 'Ugentligt',
monthly: 'Månedligt'
};
list.innerHTML = reminders.map(reminder => {
const nextCheck = formatReminderDate(reminder.next_check_at);
const createdAt = formatReminderDate(reminder.created_at);
const isActive = reminder.is_active;
const statusBadge = isActive
? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-secondary">Inaktiv</span>';
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3">
<div class="fw-bold">${reminder.title}</div>
<div class="text-muted small">${reminder.message || '-'} </div>
<div class="small text-muted mt-1">
Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
</div>
<div class="small text-muted">Næste: ${nextCheck} · Oprettet: ${createdAt}</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
${statusBadge}
<button class="btn btn-sm btn-outline-danger" onclick="deleteReminder(${reminder.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`;
}).join('');
setModuleContentState('reminders', true);
}
async function saveReminder() {
reminderUserId = await ensureReminderUserId();
if (!reminderUserId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
const title = document.getElementById('rem_title').value.trim();
const message = document.getElementById('rem_message').value.trim();
const priority = document.getElementById('rem_priority').value;
const eventType = document.getElementById('rem_event_type').value;
const triggerType = document.getElementById('rem_trigger_type').value;
const scheduledAtValue = document.getElementById('rem_scheduled_at').value;
const targetStatus = document.getElementById('rem_target_status').value;
const recurrenceType = document.getElementById('rem_recurrence_type').value;
const recurrenceDow = document.getElementById('rem_recurrence_dow').value;
const recurrenceDom = document.getElementById('rem_recurrence_dom').value;
const notifyFrontend = document.getElementById('rem_notify_frontend').checked;
const notifyEmail = document.getElementById('rem_notify_email').checked;
const notifyMattermost = document.getElementById('rem_notify_mattermost').checked;
const overridePrefs = document.getElementById('rem_override_prefs').checked;
if (!title) {
alert('Titel er påkrævet');
return;
}
let triggerConfig = {};
let scheduledAt = null;
if (triggerType === 'status_change') {
if (!targetStatus) {
alert('Vælg en status for statusændring');
return;
}
triggerConfig = { target_status: targetStatus };
} else {
if (!scheduledAtValue) {
alert('Vælg et tidspunkt');
return;
}
scheduledAt = new Date(scheduledAtValue).toISOString();
}
const payload = {
title,
message: message || null,
priority,
event_type: eventType,
trigger_type: triggerType,
trigger_config: triggerConfig,
recipient_user_ids: [Number(reminderUserId)],
recipient_emails: [],
notify_mattermost: notifyMattermost,
notify_email: notifyEmail,
notify_frontend: notifyFrontend,
override_user_preferences: overridePrefs,
recurrence_type: recurrenceType,
recurrence_day_of_week: recurrenceType === 'weekly' ? Number(recurrenceDow) : null,
recurrence_day_of_month: recurrenceType === 'monthly' ? Number(recurrenceDom) : null,
scheduled_at: scheduledAt
};
try {
const res = await fetch(`/api/v1/sag/${remindersCaseId}/reminders?user_id=${reminderUserId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette reminder');
}
bootstrap.Modal.getInstance(document.getElementById('createReminderModal')).hide();
await loadReminders();
await loadCaseCalendar();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function deleteReminder(reminderId) {
if (!confirm('Vil du slette denne reminder?')) return;
try {
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Kunne ikke slette reminder');
await loadReminders();
await loadCaseCalendar();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
function formatCalendarEvent(event) {
const dateLabel = formatReminderDate(event.start);
const typeLabelMap = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline',
deferred: 'Deferred'
};
const typeLabel = typeLabelMap[event.event_kind] || event.event_kind || 'Reminder';
return `
<a href="${event.url}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between">
<div>
<div class="fw-semibold">${event.title || 'Aftale'}</div>
<div class="text-muted small">${typeLabel} · ${dateLabel}</div>
</div>
</div>
</a>
`;
}
async function loadCaseCalendar() {
const currentList = document.getElementById('caseCalendarCurrent');
const childrenList = document.getElementById('caseCalendarChildren');
if (!currentList || !childrenList) return;
currentList.innerHTML = '<div class="text-muted small">Indlæser aftaler...</div>';
childrenList.innerHTML = '<div class="text-muted small">Indlæser børnesager...</div>';
try {
const res = await fetch(`/api/v1/sag/${remindersCaseId}/calendar-events?include_children=true`);
if (!res.ok) throw new Error('Kunne ikke hente kalenderaftaler');
const data = await res.json();
const currentEvents = data.current || [];
const childGroups = data.children || [];
const childCount = childGroups.reduce((sum, child) => sum + (child.events || []).length, 0);
const hasAnyEvents = currentEvents.length > 0 || childCount > 0;
if (!currentEvents.length) {
currentList.innerHTML = '<div class="text-muted small">Ingen aftaler for denne sag.</div>';
} else {
currentList.innerHTML = currentEvents
.map(formatCalendarEvent)
.join('');
}
if (!childGroups.length) {
childrenList.innerHTML = '<div class="text-muted small">Ingen børnesager.</div>';
} else {
childrenList.innerHTML = childGroups.map(child => {
const eventsHtml = (child.events || []).length
? child.events.map(formatCalendarEvent).join('')
: '<div class="text-muted small">Ingen aftaler.</div>';
return `
<div class="mb-3">
<div class="fw-semibold mb-1">${child.case_title}</div>
<div class="list-group">
${eventsHtml}
</div>
</div>
`;
}).join('');
}
setModuleContentState('calendar', hasAnyEvents);
} catch (e) {
console.error(e);
currentList.innerHTML = '<div class="text-danger small">Fejl ved hentning af aftaler.</div>';
childrenList.innerHTML = '';
setModuleContentState('calendar', true);
}
}
document.addEventListener('DOMContentLoaded', function() {
updateReminderTriggerFields();
updateReminderRecurrenceFields();
loadReminders();
loadCaseCalendar();
});

235
script_6.js Normal file
View File

@ -0,0 +1,235 @@
function showCreateSolutionModal() {
const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields');
if (addTimeCheckbox && timeFields) {
addTimeCheckbox.checked = false;
timeFields.classList.add('d-none');
}
const timeDate = document.getElementById('sol_time_date');
if (timeDate) timeDate.valueAsDate = new Date();
const timeHours = document.getElementById('sol_time_hours');
const timeMinutes = document.getElementById('sol_time_minutes');
const timeTotal = document.getElementById('sol_time_total');
if (timeHours) timeHours.value = '';
if (timeMinutes) timeMinutes.value = '';
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
const timeDesc = document.getElementById('sol_time_desc');
if (timeDesc) timeDesc.value = '';
const timeInternal = document.getElementById('sol_time_internal');
if (timeInternal) timeInternal.checked = false;
new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
}
function updateSolutionTimeTotal() {
const h = parseInt(document.getElementById('sol_time_hours').value) || 0;
const m = parseInt(document.getElementById('sol_time_minutes').value) || 0;
const total = h + (m / 60);
const output = document.getElementById('sol_time_total');
if (output) output.textContent = `Total: ${total.toFixed(2)} timer`;
}
async function saveSolution() {
const data = {
sag_id: document.getElementById('sol_sag_id').value,
title: document.getElementById('sol_title').value,
solution_type: document.getElementById('sol_type').value,
result: document.getElementById('sol_result').value,
description: document.getElementById('sol_desc').value,
created_by_user_id: 1 // TODO: Get from auth
};
const addTime = document.getElementById('sol_add_time')?.checked;
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
const timeMinutes = parseInt(document.getElementById('sol_time_minutes').value) || 0;
const timeTotal = timeHours + (timeMinutes / 60);
try {
const res = await fetch(`/api/v1/sag/${data.sag_id}/solution`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
if (addTime && timeTotal > 0) {
const solution = await res.json();
const timePayload = {
sag_id: data.sag_id,
solution_id: solution.id,
description: document.getElementById('sol_time_desc').value || data.title,
original_hours: timeTotal,
worked_date: document.getElementById('sol_time_date').value || null,
is_internal: document.getElementById('sol_time_internal').checked,
work_type: 'support'
};
const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(timePayload)
});
if (!timeRes.ok) {
alert('Løsning oprettet, men tid kunne ikke registreres');
}
}
window.location.reload();
} else {
alert('Fejl ved oprettelse af løsning');
}
} catch(e) { console.error(e); alert('Fejl'); }
}
function showAddTimeModal() {
// Set date to today
document.getElementById('time_date').valueAsDate = new Date();
// Reset fields
if(document.getElementById('time_total_minutes')) {
document.getElementById('time_total_minutes').value = '';
document.getElementById('time_start_input').value = '';
document.getElementById('time_end_input').value = '';
}
document.getElementById('time_desc').value = '';
if(document.getElementById('time_internal')) document.getElementById('time_internal').checked = false;
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice';
if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
new bootstrap.Modal(document.getElementById('createTimeModal')).show();
}
// Auto-calculate total hours
/* removed updateTimeTotal */
// Add listeners safely
document.addEventListener('DOMContentLoaded', () => {
const hInput = document.getElementById('time_hours_input');
const mInput = document.getElementById('time_minutes_input');
if(hInput) hInput.addEventListener('input', updateTimeTotal);
if(mInput) mInput.addEventListener('input', updateTimeTotal);
const solAddTime = document.getElementById('sol_add_time');
const solFields = document.getElementById('sol_time_fields');
if (solAddTime && solFields) {
solAddTime.addEventListener('change', () => {
solFields.classList.toggle('d-none', !solAddTime.checked);
});
}
const solHours = document.getElementById('sol_time_hours');
const solMinutes = document.getElementById('sol_time_minutes');
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
});
function bindTimeModalCalculations() {
const startIn = document.getElementById('time_start_input');
const endIn = document.getElementById('time_end_input');
const minIn = document.getElementById('time_total_minutes');
if (!startIn || !endIn || !minIn) return;
const parseTime = (val) => {
if (!val) return null;
const [h,m] = val.split(':').map(Number);
return (h * 60) + m;
};
const toTimeStr = (totalMins) => {
const h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
const recalculate = (trigger) => {
const s = parseTime(startIn.value);
const e = parseTime(endIn.value);
const dur = parseInt(minIn.value);
if (trigger === 'start' || trigger === 'end') {
if (s !== null && e !== null) {
let diff = e - s;
if (diff < 0) diff += 24*60;
minIn.value = diff;
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while (base < 0) base += 24*60;
startIn.value = toTimeStr(base);
}
} else if (trigger === 'min') {
if (s !== null && !isNaN(dur) && dur > 0) {
endIn.value = toTimeStr(s + dur);
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
let base = e - dur;
while(base < 0) base+=24*60;
startIn.value = toTimeStr(base);
}
}
};
startIn.addEventListener('change', () => recalculate('start'));
endIn.addEventListener('change', () => recalculate('end'));
minIn.addEventListener('input', () => recalculate('min'));
}
document.addEventListener('DOMContentLoaded', bindTimeModalCalculations);
async function saveTime() {
const mInput = document.getElementById('time_total_minutes');
const minVal = parseInt(mInput ? mInput.value : 0);
if (!minVal || minVal <= 0) {
alert('Indtast en gyldig varighed (minutter).');
return;
}
const totalHours = minVal / 60;
const dateVal = document.getElementById('time_date').value;
// extract optional start/end limits
const tStart = document.getElementById('time_start_input')?.value;
const tEnd = document.getElementById('time_end_input')?.value;
let startObj = null;
let endObj = null;
if (dateVal && tStart) {
try {
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
}
if (dateVal && tEnd) {
try {
const l = new Date(`${dateVal}T${tEnd}:00`);
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
}
const sagId = document.getElementById('time_sag_id').value;
const payload = {
sag_id: parseInt(sagId),
// Note: saveTime modal expects 'timer' as totalHours currently, let's keep compatibility:
timer: totalHours,
faktisk_tid_min: minVal,
worked_date: dateVal,
start_tid: startObj,
slut_tid: endObj,
description: document.getElementById('time_desc').value,
work_type: document.getElementById('time_work_type').value,
billing_method: document.getElementById('time_billing_method').value
};
try {
const res = await fetch(`/api/v1/cases/${sagId}/time`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if(res.ok) {
window.location.reload();
} else {
alert("Fejl ved registrering af tid");
}
} catch(err) {
console.error(err);
alert("Forbindelsesfejl");
}
}

3
script_7.js Normal file
View File

@ -0,0 +1,3 @@
{% endif %}

2261
script_8.js Normal file

File diff suppressed because it is too large Load Diff

544
script_9.js Normal file
View File

@ -0,0 +1,544 @@
const subscriptionCaseId = {{ case.id }};
let currentSubscription = null;
let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null;
function formatSubscriptionInterval(interval) {
const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Maaned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
};
return map[interval] || interval || '-';
}
function formatSubscriptionCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount || 0);
}
function formatSubscriptionDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
function setSubscriptionBadge(status) {
const badge = document.getElementById('subscriptionStatusBadge');
if (!badge) return;
const classes = {
'draft': 'bg-light text-dark',
'active': 'bg-success',
'paused': 'bg-warning',
'cancelled': 'bg-secondary'
};
const label = {
'draft': 'Kladde',
'active': 'Aktiv',
'paused': 'Pauset',
'cancelled': 'Opsagt'
};
badge.className = `badge ${classes[status] || 'bg-light text-dark'}`;
badge.textContent = label[status] || status || 'Ingen';
}
function showSubscriptionCreateForm() {
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.remove('d-none');
if (form) form.classList.remove('d-none');
if (details) details.classList.add('d-none');
setSubscriptionBadge(null);
const startDateInput = document.getElementById('subscriptionStartDateInput');
if (startDateInput && !startDateInput.value) {
startDateInput.value = new Date().toISOString().split('T')[0];
}
const body = document.getElementById('subscriptionLineItemsBody');
if (body) {
body.innerHTML = `
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
`;
}
populateSubscriptionProductSelects();
updateSubscriptionLineTotals();
}
function populateSubscriptionProductSelects() {
const selects = document.querySelectorAll('.subscriptionProductSelect');
selects.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Vælg produkt</option>';
subscriptionProducts.forEach(product => {
const option = document.createElement('option');
option.value = product.id;
option.textContent = product.name;
option.dataset.salesPrice = product.sales_price ?? '';
option.dataset.description = product.short_description ?? '';
select.appendChild(option);
});
if (currentValue) {
select.value = currentValue;
} else if (lastCreatedSubscriptionProductId) {
select.value = String(lastCreatedSubscriptionProductId);
}
});
lastCreatedSubscriptionProductId = null;
}
function applySubscriptionProduct(select) {
const row = select.closest('tr');
if (!row) return;
const descriptionInput = row.querySelector('input[type="text"]');
const unitPriceInput = row.querySelectorAll('input[type="number"]')[1];
const selected = select.options[select.selectedIndex];
if (!selected) return;
const description = selected.dataset.description || selected.textContent || '';
const salesPrice = selected.dataset.salesPrice;
if (descriptionInput && !descriptionInput.value.trim()) {
descriptionInput.value = description;
}
if (unitPriceInput && salesPrice !== '') {
unitPriceInput.value = salesPrice;
}
updateSubscriptionLineTotals();
}
function addSubscriptionLine() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return;
const row = document.createElement('tr');
row.innerHTML = `
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
`;
body.appendChild(row);
populateSubscriptionProductSelects();
updateSubscriptionLineTotals();
}
function removeSubscriptionLine(button) {
const row = button.closest('tr');
const body = document.getElementById('subscriptionLineItemsBody');
if (!row || !body) return;
if (body.children.length <= 1) {
row.querySelectorAll('input').forEach(input => {
input.value = input.type === 'number' ? 0 : '';
});
} else {
row.remove();
}
updateSubscriptionLineTotals();
}
function updateSubscriptionLineTotals() {
const body = document.getElementById('subscriptionLineItemsBody');
const totalEl = document.getElementById('subscriptionLinesTotal');
if (!body || !totalEl) return;
let total = 0;
Array.from(body.querySelectorAll('tr')).forEach(row => {
const inputs = row.querySelectorAll('input');
const description = inputs[0]?.value || '';
const qty = parseFloat(inputs[1]?.value || 0);
const unit = parseFloat(inputs[2]?.value || 0);
const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0);
total += lineTotal;
const lineTotalEl = row.querySelector('.subscriptionLineTotal');
if (lineTotalEl) {
lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal);
}
if (!description && qty === 0 && unit === 0) {
if (lineTotalEl) {
lineTotalEl.textContent = formatSubscriptionCurrency(0);
}
}
});
totalEl.textContent = formatSubscriptionCurrency(total);
}
function collectSubscriptionLineItems() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return [];
const items = [];
Array.from(body.querySelectorAll('tr')).forEach(row => {
const productSelect = row.querySelector('.subscriptionProductSelect');
const inputs = row.querySelectorAll('input');
const description = (inputs[0]?.value || '').trim();
const quantity = parseFloat(inputs[1]?.value || 0);
const unitPrice = parseFloat(inputs[2]?.value || 0);
if (!description && quantity === 0 && unitPrice === 0) {
return;
}
items.push({
product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null,
description,
quantity,
unit_price: unitPrice
});
});
return items;
}
async function loadSubscriptionProducts() {
try {
const res = await fetch('/api/v1/products');
if (!res.ok) {
throw new Error('Kunne ikke hente produkter');
}
subscriptionProducts = await res.json();
} catch (e) {
console.error('Error loading products:', e);
subscriptionProducts = [];
}
populateSubscriptionProductSelects();
}
function openSubscriptionProductModal() {
const form = document.getElementById('subscriptionProductForm');
if (form) form.reset();
new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show();
}
async function createSubscriptionProduct() {
const payload = {
name: document.getElementById('subscriptionProductName').value.trim(),
type: document.getElementById('subscriptionProductType').value.trim() || null,
status: document.getElementById('subscriptionProductStatus').value,
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
};
if (!payload.name) {
alert('Navn er paakraevet');
return;
}
const res = await fetch('/api/v1/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const error = await res.json();
alert(error.detail || 'Kunne ikke oprette produkt');
return;
}
const product = await res.json();
lastCreatedSubscriptionProductId = product.id;
bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide();
await loadSubscriptionProducts();
updateSubscriptionLineTotals();
}
function renderSubscription(subscription) {
currentSubscription = subscription;
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.add('d-none');
if (form) form.classList.add('d-none');
if (details) details.classList.remove('d-none');
document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`;
document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-';
document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval);
document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price);
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
// New fields
const periodStartEl = document.getElementById('subscriptionPeriodStart');
const nextInvoiceEl = document.getElementById('subscriptionNextInvoice');
if (periodStartEl) {
periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-';
}
if (nextInvoiceEl) {
const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-';
nextInvoiceEl.textContent = nextDate;
// Highlight if invoice is due soon
if (subscription.next_invoice_date) {
const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24));
if (daysUntil <= 7 && daysUntil >= 0) {
nextInvoiceEl.innerHTML = `${nextDate} <span class="badge bg-warning text-dark">Om ${daysUntil} dage</span>`;
}
}
}
setSubscriptionBadge(subscription.status);
const itemsBody = document.getElementById('subscriptionItemsBody');
const itemsTotal = document.getElementById('subscriptionItemsTotal');
if (itemsBody) {
const items = subscription.line_items || [];
if (!items.length) {
itemsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen linjer</td></tr>';
} else {
itemsBody.innerHTML = items.map(item => `
<tr>
<td>${item.product_name || '-'}</td>
<td>${item.description}</td>
<td class="text-end">${parseFloat(item.quantity).toFixed(2)}</td>
<td class="text-end">${formatSubscriptionCurrency(item.unit_price)}</td>
<td class="text-end">${formatSubscriptionCurrency(item.line_total)}</td>
</tr>
`).join('');
}
}
if (itemsTotal) {
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
}
const actions = document.getElementById('subscriptionActions');
if (!actions) return;
const buttons = [];
if (subscription.status === 'draft' || subscription.status === 'paused') {
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
}
if (subscription.status === 'active') {
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
}
if (subscription.status !== 'cancelled') {
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
}
actions.innerHTML = buttons.join(' ');
}
async function loadSubscriptionForCase() {
try {
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return;
}
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
renderSubscription(subscription);
setModuleContentState('subscription', true);
} catch (e) {
console.error('Error loading subscription:', e);
showSubscriptionCreateForm();
setModuleContentState('subscription', true);
}
}
async function createSubscription() {
const billingInterval = document.getElementById('subscriptionIntervalInput').value;
const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10);
const startDate = document.getElementById('subscriptionStartDateInput').value;
const notes = document.getElementById('subscriptionNotesInput').value.trim();
const lineItems = collectSubscriptionLineItems();
if (!billingInterval || !billingDay || !startDate) {
alert('Udfyld venligst alle paakraevet felter');
return;
}
if (!lineItems.length) {
alert('Du skal angive mindst en varelinje');
return;
}
try {
const res = await fetch('/api/v1/sag-subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: subscriptionCaseId,
billing_interval: billingInterval,
billing_day: billingDay,
start_date: startDate,
notes: notes || null,
line_items: lineItems
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
const subscription = await res.json();
renderSubscription(subscription);
} catch (e) {
alert(e.message || e);
}
}
async function updateSubscriptionStatus(status) {
if (!currentSubscription) return;
if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) {
return;
}
try {
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke opdatere status');
}
const updated = await res.json();
renderSubscription(updated);
} catch (e) {
alert(e.message || e);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadSubscriptionProducts();
loadSubscriptionForCase();
});
// === Quick Time Entry Functions (for inline time tracking) ===
function toggleQuickTimeForm() {
const container = document.getElementById('quickTimeFormContainer');
if (container) {
container.classList.remove('d-none');
}
}
// Make function globally available for onclick handler
window.toggleQuickTimeForm = toggleQuickTimeForm;
async function quickAddTime(event) {
event.preventDefault();
const form = document.getElementById('quickAddTimeForm');
const formData = new FormData(form);
// Parse hours and minutes
const hours = parseInt(formData.get('hours')) || 0;
const minutes = parseInt(formData.get('minutes')) || 0;
const totalHours = hours + (minutes / 60);
if (totalHours === 0) {
alert('Angiv venligst timer eller minutter');
return;
}
const billingSelect = document.getElementById('quickTimeBillingMethod');
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null;
let fixedPriceAgreementId = null;
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
const isInternal = billingMethod === 'internal';
// Build payload
const payload = {
sag_id: {{ case.id }},
worked_date: formData.get('date'),
original_hours: totalHours,
description: formData.get('description'),
billing_method: billingMethod,
is_internal: isInternal
};
if (prepaidCardId) {
payload.prepaid_card_id = prepaidCardId;
}
if (fixedPriceAgreementId) {
payload.fixed_price_agreement_id = fixedPriceAgreementId;
}
try {
const response = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering');
}
// Success - reload page to show new entry
window.location.reload();
} catch (error) {
alert('Fejl: ' + error.message);
console.error('Quick add time error:', error);
}
}
// Set today's date as default for quick time form
document.addEventListener('DOMContentLoaded', function() {
const dateInput = document.getElementById('quickTimeDate');
if (dateInput && !dateInput.value) {
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
}
// Activate tab from ?tab= URL parameter (used when navigating from relation tree QA menu)
const tabParam = new URLSearchParams(window.location.search).get('tab');
if (tabParam) {
const tabBtn = document.getElementById(tabParam + '-tab')
|| document.querySelector(`[data-module-tab="${tabParam}"]`);
if (tabBtn) {
setTimeout(() => {
bootstrap.Tab.getOrCreateInstance(tabBtn).show();
forceCaseTabActivation(tabParam);
}, 300);
}
}
});

View File

@ -31,6 +31,19 @@ async function sendSms(number, message, sender = null, contactId = null) {
}
const data = await response.json();
try {
window.dispatchEvent(new CustomEvent('bmc:sms-sent', {
detail: {
to,
message: String(message || ''),
sender: sender || null,
contact_id: contactId || null,
provider_result: data || null,
}
}));
} catch (e) {
console.warn('Kunne ikke udsende sms-sent event', e);
}
alert('SMS sendt ✅');
return { ok: true, data };
}

45
test_anydesk.py Normal file
View File

@ -0,0 +1,45 @@
"""Quick test of AnyDesk HMAC-SHA1 auth + live API call"""
import hashlib, hmac, base64, time, asyncio, aiohttp, json
LICENSE_ID = "1543834064287906"
API_TOKEN = "KQI35S594KAHJS5"
BASE_URL = "https://v1.api.anydesk.com:8081"
def make_auth(resource, method="GET", content=""):
sha1 = hashlib.sha1()
sha1.update(content.encode())
ch = base64.b64encode(sha1.digest()).decode()
ts = str(int(time.time()))
req = f"{method}\n{resource}\n{ts}\n{ch}"
sig = hmac.new(API_TOKEN.encode(), req.encode(), hashlib.sha1).digest()
tok = base64.b64encode(sig).decode()
return f"AD {LICENSE_ID}:{ts}:{tok}"
async def test():
end = int(time.time())
start = end - 30 * 86400 # last 30 days
resource = f"/sessions?from={start}&to={end}&limit=10"
headers = {"Authorization": make_auth(resource)}
print(f"URL: GET {BASE_URL}{resource}")
print(f"Auth: {headers['Authorization'][:70]}...")
async with aiohttp.ClientSession() as s:
async with s.get(
f"{BASE_URL}{resource}",
headers=headers,
timeout=aiohttp.ClientTimeout(total=15),
ssl=True,
) as r:
txt = await r.text()
print(f"\nStatus: {r.status}")
print(f"Response:\n{txt[:800]}")
if r.status == 200:
try:
data = json.loads(txt)
sessions = data.get("list", [])
print(f"\n✅ OK - {len(sessions)} sessions returned")
for s in sessions[:3]:
print(f" sid={s.get('sid')} | from={s.get('from',{}).get('alias','?')} | duration={s.get('duration')}s")
except Exception as e:
print(f"Parse error: {e}")
asyncio.run(test())

178
tests/api.http Normal file
View File

@ -0,0 +1,178 @@
# BMC Hub — REST Client Test Requests
# Requires VS Code extension: "REST Client" (humao.rest-client)
# Usage: Click "Send Request" above any ### block
#
# Set your base URL and auth cookie below
# @baseUrl = http://localhost:8001
@baseUrl = http://172.16.31.183:8001
# Paste a valid session cookie from browser DevTools (Network tab → any API call → Cookie header)
@authCookie = session=YOUR_SESSION_COOKIE_HERE
###
# 1. Health check
GET {{baseUrl}}/health
###
# 2. System info
GET {{baseUrl}}/api/v1/system/health
Cookie: {{authCookie}}
###
# 3. List AnyDesk sessions (all or filtered)
GET {{baseUrl}}/api/v1/anydesk/sessions
Cookie: {{authCookie}}
###
# 4. List AnyDesk sessions for specific sag
GET {{baseUrl}}/api/v1/anydesk/sessions?sag_id=53
Cookie: {{authCookie}}
###
# 5. Start AnyDesk session (dry-run by default)
POST {{baseUrl}}/api/v1/anydesk/start-session
Cookie: {{authCookie}}
Content-Type: application/json
{
"sag_id": 53,
"contact_id": null,
"customer_id": null
}
###
# 5b. Assign AnyDesk session to sag
PATCH {{baseUrl}}/api/v1/anydesk/sessions/1
Cookie: {{authCookie}}
Content-Type: application/json
{
"sag_id": 53
}
###
# 5c. End AnyDesk session
POST {{baseUrl}}/api/v1/anydesk/sessions/1/end
Cookie: {{authCookie}}
###
# 5d. Get session details
GET {{baseUrl}}/api/v1/anydesk/sessions/1
Cookie: {{authCookie}}
###
# 5e. Pull live session log from AnyDesk API (requires dry_run=false in settings)
# Uses HMAC-SHA1 auth against https://v1.api.anydesk.com:8081/sessions
POST {{baseUrl}}/api/v1/anydesk/fetch-from-api?days=30&limit=1000
Cookie: {{authCookie}}
###
# 5f. Pull log for last 90 days
POST {{baseUrl}}/api/v1/anydesk/fetch-from-api?days=90&limit=1000
Cookie: {{authCookie}}
###
# 5g. AnyDesk health / config status
GET {{baseUrl}}/api/v1/anydesk/health
Cookie: {{authCookie}}
###
# 6. Create sag (case)
POST {{baseUrl}}/api/v1/sag
Cookie: {{authCookie}}
Content-Type: application/json
{
"titel": "Test sag fra REST client",
"customer_id": 1,
"status": "åben"
}
###
# 7. Update sag title (PATCH)
PATCH {{baseUrl}}/api/v1/sag/53
Cookie: {{authCookie}}
Content-Type: application/json
{
"titel": "Opdateret sagsoverskrift"
}
###
# 8. Set deadline on sag
PATCH {{baseUrl}}/api/v1/sag/53
Cookie: {{authCookie}}
Content-Type: application/json
{
"deadline": "2026-04-30"
}
###
# 9. List time entries for a sag
GET {{baseUrl}}/api/v1/timetracking/time?sag_id=53
Cookie: {{authCookie}}
###
# 10. Start live timer
POST {{baseUrl}}/api/v1/timetracking/time/start
Cookie: {{authCookie}}
Content-Type: application/json
{
"sag_id": 53,
"description": "Test timer fra REST client"
}
###
# 11. Stop live timer
POST {{baseUrl}}/api/v1/timetracking/time/stop
Cookie: {{authCookie}}
Content-Type: application/json
{
"sag_id": 53
}
###
# 12. Create manual time entry
POST {{baseUrl}}/api/v1/timetracking/time/manual
Cookie: {{authCookie}}
Content-Type: application/json
{
"sag_id": 53,
"faktisk_tid_min": 30,
"description": "Manuel registrering fra REST client",
"worked_date": "2026-03-27"
}
###
# 13. List call history
GET {{baseUrl}}/api/v1/telefoni/calls?limit=20
Cookie: {{authCookie}}
###
# 14. Link call to sag
PATCH {{baseUrl}}/api/v1/telefoni/calls/1
Cookie: {{authCookie}}
Content-Type: application/json
{
"sag_id": 53
}
###
# 15. Search contacts (partial name)
GET {{baseUrl}}/api/v1/search/contacts?q=Martin
Cookie: {{authCookie}}
###
# 16. Hardware by contact
GET {{baseUrl}}/api/v1/hardware/by-contact/1
Cookie: {{authCookie}}
###
# 17. Timetracking wizard - stats
GET {{baseUrl}}/api/v1/timetracking/wizard/stats
Cookie: {{authCookie}}

112
tmp_check_eset_machine.py Normal file
View File

@ -0,0 +1,112 @@
import asyncio
import json
from app.services.eset_service import eset_service
TARGET = "ati-w11-yoga.norva24.lcl".lower()
def parse_devices(payload):
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
return payload.get("devices") or payload.get("items") or payload.get("results") or payload.get("data") or []
def get_next(payload):
if not isinstance(payload, dict):
return None
return payload.get("nextPageToken") or payload.get("next_page_token") or payload.get("nextPage")
def pick(dev, *keys):
for key in keys:
val = dev.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return ""
def extract_first_str(payload, keys):
if payload is None:
return None
key_set = {k.lower() for k in keys}
stack = [payload]
while stack:
cur = stack.pop()
if isinstance(cur, dict):
for k, v in cur.items():
if k.lower() in key_set and isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(cur, list):
for item in cur:
if isinstance(item, (dict, list)):
stack.append(item)
return None
async def main():
page_token = None
page_size = 200
max_pages = 50
found = None
for _ in range(max_pages):
payload = await eset_service.list_devices(page_size=page_size, page_token=page_token)
if not payload:
print("ERROR: No payload from ESET list_devices")
return
devices = parse_devices(payload)
for device in devices:
name = pick(device, "displayName", "deviceName", "name").lower()
fqdn = pick(device, "fqdn", "dnsName", "hostName", "hostname").lower()
if TARGET in {name, fqdn} or TARGET in name or TARGET in fqdn:
found = device
break
if found:
break
page_token = get_next(payload)
if not page_token or not devices:
break
if not found:
print("NOT_FOUND")
return
uuid = pick(found, "deviceUuid", "uuid", "id")
details = await eset_service.get_device_details(uuid)
if not details:
print("FOUND_BUT_NO_DETAILS")
print("uuid=", uuid)
return
software = eset_service.extract_installed_software(details)
summary = {
"device_uuid": uuid,
"device_name": extract_first_str(details, ["displayName", "deviceName", "name"]),
"user_identifier": extract_first_str(details, [
"userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser", "owner", "ownerUuid"
]),
"serial": extract_first_str(details, ["serialNumber", "serial", "serial_number"]),
"group": extract_first_str(details, ["parentGroup", "groupPath", "group", "path"]),
"installed_software_count": len(software),
"installed_software_first_20": software[:20],
}
out_path = "/tmp/eset_ati_w11_yoga.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump({"summary": summary, "raw": details}, f, ensure_ascii=False, indent=2)
print("FOUND")
print(json.dumps(summary, ensure_ascii=False, indent=2))
print("SAVED=", out_path)
if __name__ == "__main__":
asyncio.run(main())