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:
parent
5b24c5d978
commit
bc504b9257
13
.env.example
13
.env.example
@ -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)
|
||||
# =====================================================
|
||||
|
||||
@ -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
142
add_css.py
Normal 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!')
|
||||
14
app/anydesk/backend/views.py
Normal file
14
app/anydesk/backend/views.py
Normal 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"},
|
||||
)
|
||||
1165
app/anydesk/frontend/sessions.html
Normal file
1165
app/anydesk/frontend/sessions.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
app/modules/links/README.md
Normal file
16
app/modules/links/README.md
Normal 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`).
|
||||
8
app/modules/links/__init__.py
Normal file
8
app/modules/links/__init__.py
Normal 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"
|
||||
0
app/modules/links/backend/__init__.py
Normal file
0
app/modules/links/backend/__init__.py
Normal file
279
app/modules/links/backend/router.py
Normal file
279
app/modules/links/backend/router.py
Normal 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
|
||||
229
app/modules/links/backend/service.py
Normal file
229
app/modules/links/backend/service.py
Normal 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,
|
||||
)
|
||||
0
app/modules/links/frontend/__init__.py
Normal file
0
app/modules/links/frontend/__init__.py
Normal file
17
app/modules/links/frontend/views.py
Normal file
17
app/modules/links/frontend/views.py
Normal 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},
|
||||
)
|
||||
0
app/modules/links/jobs/__init__.py
Normal file
0
app/modules/links/jobs/__init__.py
Normal file
18
app/modules/links/jobs/dead_link_check.py
Normal file
18
app/modules/links/jobs/dead_link_check.py
Normal 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))
|
||||
0
app/modules/links/models/__init__.py
Normal file
0
app/modules/links/models/__init__.py
Normal file
123
app/modules/links/models/schemas.py
Normal file
123
app/modules/links/models/schemas.py
Normal 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
|
||||
19
app/modules/links/templates/index.html
Normal file
19
app/modules/links/templates/index.html
Normal 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 %}
|
||||
@ -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,6 +2537,16 @@ 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.
|
||||
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
|
||||
@ -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,7 +2688,12 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
|
||||
sag_id,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
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:
|
||||
try:
|
||||
insert_email_query = """
|
||||
INSERT INTO email_messages (
|
||||
message_id, subject, sender_email, sender_name,
|
||||
@ -2695,17 +2740,80 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
|
||||
sag_id,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
insert_error = e
|
||||
logger.warning("⚠️ Outbound email medium 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")
|
||||
# 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)
|
||||
|
||||
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:
|
||||
if smtp_attachments and email_id:
|
||||
from psycopg2 import Binary
|
||||
|
||||
for attachment in smtp_attachments:
|
||||
try:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO email_attachments (
|
||||
@ -2722,7 +2830,16 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
|
||||
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,
|
||||
e,
|
||||
)
|
||||
|
||||
linked_ok = False
|
||||
try:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO sag_emails (sag_id, email_id)
|
||||
@ -2731,6 +2848,23 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
|
||||
""",
|
||||
(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(
|
||||
"""
|
||||
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 kommentar_id, created_at
|
||||
RETURNING {returning_expr}, created_at
|
||||
""",
|
||||
(sag_id, 'Email Bot', outgoing_comment, True),
|
||||
) or {}
|
||||
)
|
||||
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",
|
||||
|
||||
@ -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
@ -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")
|
||||
|
||||
@ -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,
|
||||
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,
|
||||
|
||||
@ -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
|
||||
"""
|
||||
|
||||
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, (suffix8, suffix8))
|
||||
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
|
||||
""",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
try:
|
||||
body_str = json.dumps(data) if data else ""
|
||||
auth_header = self._generate_auth_header(endpoint, body_str, method)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json"
|
||||
"Authorization": auth_header,
|
||||
"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)
|
||||
try:
|
||||
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:
|
||||
return {"error": f"Unsupported method: {method}"}
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
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,
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
""",
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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">×</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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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":
|
||||
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
22
apply_migration_150.py
Normal 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
1
final_wc.txt
Normal file
@ -0,0 +1 @@
|
||||
11989 app/modules/sag/templates/detail.html
|
||||
18
fix_domcontent.py
Normal file
18
fix_domcontent.py
Normal 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")
|
||||
19
fix_getTimeV1EmployeeId.py
Normal file
19
fix_getTimeV1EmployeeId.py
Normal 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
0
fix_js2.py
Normal file
13
fix_tab.py
Normal file
13
fix_tab.py
Normal 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
22
fix_time_modal.py
Normal 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
223
fix_timeline_clean.py
Normal 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 ? ` · <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
195
fix_timeline_colors.py
Normal 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)} • ${hStr}${desc ? ' · <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
9
get_js.py
Normal 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
6
get_saveTime.py
Normal 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
23
main.py
@ -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")
|
||||
|
||||
7
migrations/152_users_profile_fields.sql
Normal file
7
migrations/152_users_profile_fields.sql
Normal 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);
|
||||
21
migrations/153_user_anydesk_ids.sql
Normal file
21
migrations/153_user_anydesk_ids.sql
Normal 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;
|
||||
119
migrations/154_links_endpoints_module.sql
Normal file
119
migrations/154_links_endpoints_module.sql
Normal 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;
|
||||
42
migrations/155_links_permissions.sql
Normal file
42
migrations/155_links_permissions.sql
Normal 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
54
old_js.txt
Normal 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;
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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
323
patch_detail.py
Normal 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} • ${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
741
patch_everything.py
Normal 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} • ${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
207
patch_time_form.py
Normal 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
171
patch_time_modal.py
Normal 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
1
patcher.py
Normal file
@ -0,0 +1 @@
|
||||
import os
|
||||
9
print_saveTime.py
Normal file
9
print_saveTime.py
Normal 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
1
result.txt
Normal file
@ -0,0 +1 @@
|
||||
2
|
||||
15
run_anydesk_import.py
Normal file
15
run_anydesk_import.py
Normal 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
43
script_0.js
Normal 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
1433
script_1.js
Normal file
File diff suppressed because it is too large
Load Diff
918
script_10.js
Normal file
918
script_10.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
// ── 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 & 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
186
script_11.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
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
578
script_2.js
Normal file
@ -0,0 +1,578 @@
|
||||
|
||||
function _escapeCommentHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
208
script_3.js
Normal 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
356
script_4.js
Normal 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} • ${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
344
script_5.js
Normal 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
235
script_6.js
Normal 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
3
script_7.js
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
2261
script_8.js
Normal file
2261
script_8.js
Normal file
File diff suppressed because it is too large
Load Diff
544
script_9.js
Normal file
544
script_9.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
45
test_anydesk.py
Normal 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
178
tests/api.http
Normal 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
112
tmp_check_eset_machine.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user