Compare commits
6 Commits
43fd651723
...
ba601e38b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba601e38b1 | ||
|
|
30d1be61eb | ||
|
|
bc504b9257 | ||
|
|
5b24c5d978 | ||
|
|
9f563941e6 | ||
|
|
205c0dab07 |
19
.env.example
19
.env.example
@ -16,6 +16,11 @@ API_HOST=0.0.0.0
|
|||||||
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
|
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
|
||||||
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
|
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
|
||||||
|
|
||||||
|
# FirmaAPI (CVR company lookup)
|
||||||
|
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||||
|
FIRMAAPI_API_KEY=
|
||||||
|
FIRMAAPI_TIMEOUT_SECONDS=12
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# SECURITY
|
# SECURITY
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -69,6 +74,20 @@ NEXTCLOUD_CACHE_TTL_SECONDS=300
|
|||||||
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
NEXTCLOUD_ENCRYPTION_KEY=
|
NEXTCLOUD_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# Links / Endpoints Module (Optional)
|
||||||
|
# =====================================================
|
||||||
|
LINKS_MODULE_ENABLED=false
|
||||||
|
LINKS_READ_ONLY=true
|
||||||
|
LINKS_DRY_RUN=true
|
||||||
|
LINKS_DEAD_LINK_CHECK_ENABLED=true
|
||||||
|
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
|
||||||
|
LINKS_CHECK_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
|
# Vaultwarden (Bitwarden-compatible)
|
||||||
|
VAULTWARDEN_BASE_URL=
|
||||||
|
VAULTWARDEN_API_TOKEN=
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# vTiger Cloud Integration (Required for Subscriptions)
|
# vTiger Cloud Integration (Required for Subscriptions)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -44,6 +44,11 @@ API_HOST=0.0.0.0
|
|||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
API_RELOAD=false
|
API_RELOAD=false
|
||||||
|
|
||||||
|
# FirmaAPI (CVR company lookup)
|
||||||
|
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||||
|
FIRMAAPI_API_KEY=
|
||||||
|
FIRMAAPI_TIMEOUT_SECONDS=12
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# SECURITY - Production
|
# SECURITY - Production
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -76,3 +81,18 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
|
|||||||
# VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer på samme linje
|
# VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer på samme linje
|
||||||
ECONOMIC_READ_ONLY=true
|
ECONOMIC_READ_ONLY=true
|
||||||
ECONOMIC_DRY_RUN=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
|
||||||
|
LINKS_CHECK_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
|
# Vaultwarden (Bitwarden-compatible)
|
||||||
|
VAULTWARDEN_BASE_URL=
|
||||||
|
VAULTWARDEN_API_TOKEN=
|
||||||
|
|||||||
@ -107,6 +107,23 @@ if settings.ECONOMIC_READ_ONLY:
|
|||||||
logger.warning("Read-only mode")
|
logger.warning("Read-only mode")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Migration Validation
|
||||||
|
```bash
|
||||||
|
# Validate root migrations against current PostgreSQL schema
|
||||||
|
python scripts/validate_migrations.py
|
||||||
|
|
||||||
|
# Include module-specific migration directory in validation
|
||||||
|
python scripts/validate_migrations.py --module app/modules/sag/migrations
|
||||||
|
|
||||||
|
# Machine-readable report and strict index validation
|
||||||
|
python scripts/validate_migrations.py --json --strict-indexes
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
- `0`: Schema is aligned, or only index differences were found without strict mode.
|
||||||
|
- `1`: Schema mismatches were found (missing tables/columns, or missing indexes with strict mode).
|
||||||
|
- `2`: Runtime error (for example connection/configuration issues).
|
||||||
|
|
||||||
## 🐳 Docker Commands
|
## 🐳 Docker Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libzbar0 \
|
||||||
gcc \
|
gcc \
|
||||||
g++ \
|
g++ \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
|
|||||||
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.auth_service import AuthService
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.auth_dependencies import get_current_user
|
from app.core.auth_dependencies import get_current_user
|
||||||
|
from app.core.database import execute_query
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -207,3 +208,101 @@ async def disable_2fa(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"message": "2FA disabled"}
|
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"}
|
||||||
|
|||||||
@ -4,6 +4,53 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
.contacts-toolbar {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-slot {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 460px;
|
||||||
|
width: min(46vw, 460px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap .header-search {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.45rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 0;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear.d-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
@ -21,6 +68,139 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contacts-shell {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 32, 71, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-table-wrap {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: min(68vh, 780px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table > :not(caption) > * > * {
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
padding-bottom: 0.85rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table-hover > tbody > tr:hover {
|
||||||
|
--bs-table-accent-bg: rgba(15, 76, 117, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table tbody tr:nth-child(even) {
|
||||||
|
background: rgba(15, 76, 117, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table thead th {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 1px 0 rgba(15, 76, 117, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-subline {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-main {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-quick-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-quick-actions .btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.08rem 0.52rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-count-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.2);
|
||||||
|
background: rgba(15, 76, 117, 0.06);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.58rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.24rem 0.62rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.active {
|
||||||
|
background: rgba(17, 153, 84, 0.12);
|
||||||
|
border-color: rgba(17, 153, 84, 0.24);
|
||||||
|
color: #0b6b3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.inactive {
|
||||||
|
background: rgba(108, 117, 125, 0.13);
|
||||||
|
border-color: rgba(108, 117, 125, 0.24);
|
||||||
|
color: #5b6570;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-table-action {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--accent);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-table-action:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.08);
|
||||||
|
border-color: rgba(15, 76, 117, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.contact-avatar {
|
.contact-avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -32,6 +212,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(15, 76, 117, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-btn {
|
.pagination-btn {
|
||||||
@ -52,21 +233,136 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-contact-modal .modal-content {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 22px 50px rgba(2, 32, 71, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-contact-modal .modal-header {
|
||||||
|
border-bottom: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
background: linear-gradient(180deg, rgba(15, 76, 117, 0.06) 0%, rgba(15, 76, 117, 0.02) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-contact-modal .modal-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-picker {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: rgba(15, 76, 117, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-search-input {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-result-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-result-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-result-item:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-result-item.selected {
|
||||||
|
background: rgba(15, 76, 117, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-companies {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-chip {
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.25);
|
||||||
|
background: rgba(15, 76, 117, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
padding: 0.22rem 0.55rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-chip button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.contacts-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-slot {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
<div class="d-flex justify-content-between align-items-center mb-5 contacts-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="fw-bold mb-1">Kontakter</h2>
|
<h2 class="fw-bold mb-1">Kontakter</h2>
|
||||||
<p class="text-muted mb-0">Administrer kontaktpersoner</p>
|
<p class="text-muted mb-0">Administrer kontaktpersoner</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
<div class="toolbar-search-slot">
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon...">
|
<div class="search-wrap">
|
||||||
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
<input type="search" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon eller firma..." autocomplete="off" spellcheck="false">
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
|
||||||
</button>
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
<div class="mb-4 d-flex gap-2 flex-wrap">
|
||||||
@ -81,9 +377,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4 contacts-shell">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive contacts-table-wrap">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle contacts-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Navn</th>
|
<th>Navn</th>
|
||||||
@ -123,7 +419,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Contact Modal -->
|
<!-- Create Contact Modal -->
|
||||||
<div class="modal fade" id="createContactModal" tabindex="-1">
|
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@ -175,10 +471,19 @@
|
|||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Firmaer</label>
|
<label class="form-label">Firmaer</label>
|
||||||
<select class="form-select" id="companySelect" multiple size="5">
|
<div class="company-picker">
|
||||||
<!-- Populated dynamically -->
|
<input
|
||||||
</select>
|
type="search"
|
||||||
<div class="form-text">Hold Ctrl/Cmd nede for at vælge flere firmaer</div>
|
id="companySearchInput"
|
||||||
|
class="form-control company-search-input"
|
||||||
|
placeholder="Søg firma..."
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
|
<div class="company-results" id="companyResults"></div>
|
||||||
|
<div class="selected-companies" id="selectedCompanies"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Vælg et eller flere firmaer ved at søge og klikke.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@ -292,22 +597,72 @@ let pageSize = 20;
|
|||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let totalContacts = 0;
|
let totalContacts = 0;
|
||||||
|
let searchTimeout = null;
|
||||||
|
let currentRequestController = null;
|
||||||
|
let lastLoadedQueryKey = '';
|
||||||
|
let availableCompanies = [];
|
||||||
|
let selectedCompanyIds = new Set();
|
||||||
|
|
||||||
// Load contacts on page load
|
// Load contacts on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadContacts();
|
loadContacts();
|
||||||
loadCompaniesForSelect();
|
loadCompaniesForSelect();
|
||||||
|
|
||||||
// Search with debounce
|
const searchInput = document.getElementById('searchInput');
|
||||||
let searchTimeout;
|
const clearBtn = document.getElementById('searchClearBtn');
|
||||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
||||||
|
const triggerSearch = () => {
|
||||||
|
const nextSearch = searchInput.value.trim();
|
||||||
|
if (nextSearch === searchQuery) {
|
||||||
|
toggleClearButton(nextSearch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchQuery = nextSearch;
|
||||||
|
currentPage = 0;
|
||||||
|
toggleClearButton(searchQuery);
|
||||||
|
loadContacts();
|
||||||
|
};
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
|
toggleClearButton(e.target.value.trim());
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
searchQuery = e.target.value;
|
triggerSearch();
|
||||||
currentPage = 0;
|
|
||||||
loadContacts();
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (!searchInput.value) {
|
||||||
|
toggleClearButton('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
if (!searchInput.value) {
|
||||||
|
toggleClearButton('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('companySearchInput')?.addEventListener('input', (e) => {
|
||||||
|
renderCompanyResults(e.target.value || '');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setFilter(filter) {
|
function setFilter(filter) {
|
||||||
@ -327,6 +682,11 @@ async function loadContacts() {
|
|||||||
const tbody = document.getElementById('contactsTableBody');
|
const tbody = document.getElementById('contactsTableBody');
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
||||||
|
|
||||||
|
if (currentRequestController) {
|
||||||
|
currentRequestController.abort();
|
||||||
|
}
|
||||||
|
currentRequestController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build query parameters
|
// Build query parameters
|
||||||
let params = new URLSearchParams({
|
let params = new URLSearchParams({
|
||||||
@ -344,7 +704,13 @@ async function loadContacts() {
|
|||||||
params.append('is_active', 'false');
|
params.append('is_active', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/contacts?${params}`);
|
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
|
||||||
|
if (queryKey === lastLoadedQueryKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastLoadedQueryKey = queryKey;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
totalContacts = data.total;
|
totalContacts = data.total;
|
||||||
@ -352,11 +718,20 @@ async function loadContacts() {
|
|||||||
updatePagination(data.total);
|
updatePagination(data.total);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Failed to load contacts:', error);
|
console.error('Failed to load contacts:', error);
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</td></tr>';
|
||||||
|
} finally {
|
||||||
|
currentRequestController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleClearButton(value) {
|
||||||
|
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
|
||||||
|
}
|
||||||
|
|
||||||
function displayContacts(contacts) {
|
function displayContacts(contacts) {
|
||||||
const tbody = document.getElementById('contactsTableBody');
|
const tbody = document.getElementById('contactsTableBody');
|
||||||
|
|
||||||
@ -368,8 +743,8 @@ function displayContacts(contacts) {
|
|||||||
tbody.innerHTML = contacts.map(contact => {
|
tbody.innerHTML = contacts.map(contact => {
|
||||||
const initials = getInitials(contact.first_name, contact.last_name);
|
const initials = getInitials(contact.first_name, contact.last_name);
|
||||||
const statusBadge = contact.is_active
|
const statusBadge = contact.is_active
|
||||||
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>'
|
? '<span class="status-pill active">Aktiv</span>'
|
||||||
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
: '<span class="status-pill inactive">Inaktiv</span>';
|
||||||
|
|
||||||
const companyCount = contact.company_count || 0;
|
const companyCount = contact.company_count || 0;
|
||||||
const companyNames = contact.company_names || [];
|
const companyNames = contact.company_names || [];
|
||||||
@ -389,36 +764,41 @@ function displayContacts(contacts) {
|
|||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
const smsLine = mobileLine || phoneLine;
|
const smsLine = mobileLine || phoneLine;
|
||||||
|
const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-');
|
||||||
|
const safeDepartment = escapeHtml(contact.department || '-');
|
||||||
|
const safeEmail = escapeHtml(contact.email || '-');
|
||||||
|
const safeTitle = escapeHtml(contact.title || '-');
|
||||||
|
const companiesTitle = escapeHtml(companyNames.join(', '));
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})">
|
<tr onclick="viewContact(${contact.id})">
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="contact-avatar me-3">${initials}</div>
|
<div class="contact-avatar me-3">${initials}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold">${escapeHtml(contact.first_name + ' ' + contact.last_name)}</div>
|
<div class="contact-name">${safeName}</div>
|
||||||
<div class="small text-muted">${contact.department || '-'}</div>
|
<div class="contact-subline">${safeDepartment}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-medium">${contact.email || '-'}</div>
|
<div class="contact-info-main">${safeEmail}</div>
|
||||||
${smsLine}
|
<div class="contact-quick-actions">${smsLine}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">${contact.title || '-'}</td>
|
<td class="text-muted">${safeTitle}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-light text-dark border" title="${companyNames.join(', ')}">
|
<span class="company-count-chip" title="${companiesTitle}">
|
||||||
<i class="bi bi-building me-1"></i>${companyCount}
|
<i class="bi bi-building"></i>${companyCount}
|
||||||
</span>
|
</span>
|
||||||
${companyDisplay !== '-' ? '<div class="small text-muted">' + companyDisplay + '</div>' : ''}
|
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewContact(${contact.id})">
|
<button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); viewContact(${contact.id})" title="Vis kontakt">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editContact(${contact.id})">
|
<button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); editContact(${contact.id})" title="Rediger kontakt">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -581,19 +961,87 @@ async function loadCompaniesForSelect() {
|
|||||||
const response = await fetch('/api/v1/customers?limit=1000');
|
const response = await fetch('/api/v1/customers?limit=1000');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const select = document.getElementById('companySelect');
|
availableCompanies = Array.isArray(data.customers)
|
||||||
select.innerHTML = data.customers.map(c =>
|
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
|
||||||
`<option value="${c.id}">${escapeHtml(c.name)}</option>`
|
: [];
|
||||||
).join('');
|
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
|
||||||
|
renderSelectedCompanies();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load companies:', error);
|
console.error('Failed to load companies:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCompanyResults(query) {
|
||||||
|
const host = document.getElementById('companyResults');
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const needle = String(query || '').trim().toLowerCase();
|
||||||
|
let list = availableCompanies;
|
||||||
|
if (needle) {
|
||||||
|
list = availableCompanies.filter((c) => c.name.toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
list = list.slice(0, 80);
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
host.innerHTML = '<div class="px-3 py-2 text-muted small">Ingen firmaer fundet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.innerHTML = list.map((c) => {
|
||||||
|
const selected = selectedCompanyIds.has(c.id);
|
||||||
|
return `
|
||||||
|
<button type="button" class="company-result-item ${selected ? 'selected' : ''}" onclick="toggleCompanySelection(${c.id})">
|
||||||
|
<span>${escapeHtml(c.name)}</span>
|
||||||
|
<span>${selected ? '<i class="bi bi-check2"></i>' : ''}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCompanySelection(companyId) {
|
||||||
|
const id = Number(companyId);
|
||||||
|
if (!Number.isFinite(id)) return;
|
||||||
|
|
||||||
|
if (selectedCompanyIds.has(id)) {
|
||||||
|
selectedCompanyIds.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedCompanyIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSelectedCompanies();
|
||||||
|
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedCompanies() {
|
||||||
|
const host = document.getElementById('selectedCompanies');
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const selected = availableCompanies.filter((c) => selectedCompanyIds.has(c.id));
|
||||||
|
if (!selected.length) {
|
||||||
|
host.innerHTML = '<span class="text-muted small">Ingen firmaer valgt</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.innerHTML = selected.map((c) => `
|
||||||
|
<span class="company-chip">
|
||||||
|
${escapeHtml(c.name)}
|
||||||
|
<button type="button" title="Fjern" onclick="toggleCompanySelection(${c.id})"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function showCreateContactModal() {
|
function showCreateContactModal() {
|
||||||
// Reset form
|
// Reset form
|
||||||
document.getElementById('createContactForm').reset();
|
document.getElementById('createContactForm').reset();
|
||||||
document.getElementById('isActiveInput').checked = true;
|
document.getElementById('isActiveInput').checked = true;
|
||||||
|
selectedCompanyIds = new Set();
|
||||||
|
const companySearchInput = document.getElementById('companySearchInput');
|
||||||
|
if (companySearchInput) {
|
||||||
|
companySearchInput.value = '';
|
||||||
|
}
|
||||||
|
renderCompanyResults('');
|
||||||
|
renderSelectedCompanies();
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
|
const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
|
||||||
@ -610,8 +1058,7 @@ async function createContact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get selected company IDs
|
// Get selected company IDs
|
||||||
const companySelect = document.getElementById('companySelect');
|
const companyIds = Array.from(selectedCompanyIds);
|
||||||
const companyIds = Array.from(companySelect.selectedOptions).map(opt => parseInt(opt.value));
|
|
||||||
|
|
||||||
const contactData = {
|
const contactData = {
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
|
|||||||
@ -31,6 +31,11 @@ class Settings(BaseSettings):
|
|||||||
APIGW_TOKEN: str = ""
|
APIGW_TOKEN: str = ""
|
||||||
APIGW_TIMEOUT_SECONDS: int = 12
|
APIGW_TIMEOUT_SECONDS: int = 12
|
||||||
|
|
||||||
|
# FirmaAPI (CVR company data)
|
||||||
|
FIRMAAPI_BASE_URL: str = "https://firmaapi.dk/api/v1"
|
||||||
|
FIRMAAPI_API_KEY: str = ""
|
||||||
|
FIRMAAPI_TIMEOUT_SECONDS: int = 12
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||||
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
|
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
|
||||||
@ -70,6 +75,18 @@ class Settings(BaseSettings):
|
|||||||
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
|
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
|
||||||
NEXTCLOUD_ENCRYPTION_KEY: str = ""
|
NEXTCLOUD_ENCRYPTION_KEY: str = ""
|
||||||
|
|
||||||
|
# Links / Endpoints Module
|
||||||
|
LINKS_MODULE_ENABLED: bool = False
|
||||||
|
LINKS_READ_ONLY: bool = True
|
||||||
|
LINKS_DRY_RUN: bool = True
|
||||||
|
LINKS_DEAD_LINK_CHECK_ENABLED: bool = True
|
||||||
|
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60
|
||||||
|
LINKS_CHECK_TIMEOUT_SECONDS: int = 5
|
||||||
|
|
||||||
|
# Vaultwarden (Bitwarden-compatible)
|
||||||
|
VAULTWARDEN_BASE_URL: str = ""
|
||||||
|
VAULTWARDEN_API_TOKEN: str = ""
|
||||||
|
|
||||||
# Wiki.js Integration
|
# Wiki.js Integration
|
||||||
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
|
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
|
||||||
WIKI_API_TOKEN: str = ""
|
WIKI_API_TOKEN: str = ""
|
||||||
@ -227,9 +244,10 @@ class Settings(BaseSettings):
|
|||||||
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
||||||
|
|
||||||
# AnyDesk Remote Support Integration
|
# AnyDesk Remote Support Integration
|
||||||
|
ANYDESK_API_URL: str = "https://v1.api.anydesk.com:8081" # AnyDesk REST API base URL
|
||||||
ANYDESK_LICENSE_ID: str = ""
|
ANYDESK_LICENSE_ID: str = ""
|
||||||
ANYDESK_API_TOKEN: str = ""
|
ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com
|
||||||
ANYDESK_PASSWORD: str = ""
|
ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN
|
||||||
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
|
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
|
||||||
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
||||||
ANYDESK_TIMEOUT_SECONDS: int = 30
|
ANYDESK_TIMEOUT_SECONDS: int = 30
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import asyncio
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.cvr_service import get_cvr_service
|
from app.services.cvr_service import get_cvr_service
|
||||||
from app.services.customer_activity_logger import CustomerActivityLogger
|
from app.services.customer_activity_logger import CustomerActivityLogger
|
||||||
@ -81,7 +81,8 @@ async def list_customers(
|
|||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
search: Optional[str] = Query(default=None),
|
search: Optional[str] = Query(default=None),
|
||||||
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
|
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
|
||||||
is_active: Optional[bool] = Query(default=None)
|
is_active: Optional[bool] = Query(default=None),
|
||||||
|
vip: Optional[bool] = Query(default=None)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List customers with pagination and filtering
|
List customers with pagination and filtering
|
||||||
@ -138,6 +139,19 @@ async def list_customers(
|
|||||||
query += " AND c.is_active = %s"
|
query += " AND c.is_active = %s"
|
||||||
params.append(is_active)
|
params.append(is_active)
|
||||||
|
|
||||||
|
# Add VIP filter (customer tagged with "vip")
|
||||||
|
if vip is True:
|
||||||
|
query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'customer'
|
||||||
|
AND et.entity_id = c.id
|
||||||
|
AND LOWER(t.name) = 'vip'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
query += """
|
query += """
|
||||||
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
|
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
|
||||||
ORDER BY c.name
|
ORDER BY c.name
|
||||||
@ -170,6 +184,18 @@ async def list_customers(
|
|||||||
count_query += " AND is_active = %s"
|
count_query += " AND is_active = %s"
|
||||||
count_params.append(is_active)
|
count_params.append(is_active)
|
||||||
|
|
||||||
|
if vip is True:
|
||||||
|
count_query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'customer'
|
||||||
|
AND et.entity_id = customers.id
|
||||||
|
AND LOWER(t.name) = 'vip'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
count_result = execute_query_single(count_query, tuple(count_params))
|
count_result = execute_query_single(count_query, tuple(count_params))
|
||||||
total = count_result['total'] if count_result else 0
|
total = count_result['total'] if count_result else 0
|
||||||
|
|
||||||
|
|||||||
@ -245,6 +245,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-light btn-sm" href="/links?customer_id={{ customer_id }}" title="Se links/endpoints for denne kunde">
|
||||||
|
<i class="bi bi-link-45deg me-2"></i>Links
|
||||||
|
</a>
|
||||||
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
|
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
||||||
</button>
|
</button>
|
||||||
@ -309,6 +312,11 @@
|
|||||||
<i class="bi bi-people"></i>Kontakter
|
<i class="bi bi-people"></i>Kontakter
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#cases">
|
||||||
|
<i class="bi bi-list-check"></i>Sager
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
||||||
<i class="bi bi-chat-left-text"></i>Kontakt
|
<i class="bi bi-chat-left-text"></i>Kontakt
|
||||||
@ -344,6 +352,11 @@
|
|||||||
<i class="bi bi-hdd"></i>Hardware
|
<i class="bi bi-hdd"></i>Hardware
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#links">
|
||||||
|
<i class="bi bi-link-45deg"></i>Links
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item d-none" id="nextcloudTabNav">
|
<li class="nav-item d-none" id="nextcloudTabNav">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
|
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
|
||||||
<i class="bi bi-cloud"></i>Nextcloud
|
<i class="bi bi-cloud"></i>Nextcloud
|
||||||
@ -519,6 +532,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cases Tab -->
|
||||||
|
<div class="tab-pane fade" id="cases">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-0">Kundens sager</h5>
|
||||||
|
<small class="text-muted">Alle sager knyttet til denne kunde</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-sm btn-primary" href="/sag/new?customer_id={{ customer_id }}">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret sag
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/sag?customer_id={{ customer_id }}">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn i sagsmodul
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" id="customerCasesContainer">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="customerCasesEmpty" class="text-center py-5 text-muted d-none">
|
||||||
|
Ingen sager fundet for denne kunde
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Kontakt Tab -->
|
<!-- Kontakt Tab -->
|
||||||
<div class="tab-pane fade" id="kontakt">
|
<div class="tab-pane fade" id="kontakt">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -748,6 +803,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Links Tab -->
|
||||||
|
<div class="tab-pane fade" id="links">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-0">Links / Endpoints</h5>
|
||||||
|
<small class="text-muted">Driftslinks knyttet til denne kunde</small>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id={{ customer_id }}">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn fuld visning
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" id="customerLinksContainer">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Mål</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="customerLinksEmpty" class="text-center py-5 text-muted d-none">
|
||||||
|
Ingen links fundet for denne kunde
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Nextcloud Tab -->
|
<!-- Nextcloud Tab -->
|
||||||
<div class="tab-pane fade d-none" id="nextcloud">
|
<div class="tab-pane fade d-none" id="nextcloud">
|
||||||
{% include "modules/nextcloud/templates/tab.html" %}
|
{% include "modules/nextcloud/templates/tab.html" %}
|
||||||
@ -1210,6 +1301,11 @@ let customerKontaktFilter = 'all';
|
|||||||
|
|
||||||
let eventListenersAdded = false;
|
let eventListenersAdded = false;
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (eventListenersAdded) {
|
if (eventListenersAdded) {
|
||||||
console.log('Event listeners already added, skipping...');
|
console.log('Event listeners already added, skipping...');
|
||||||
@ -1226,6 +1322,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const casesTab = document.querySelector('a[href="#cases"]');
|
||||||
|
if (casesTab) {
|
||||||
|
casesTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerCases();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
||||||
if (kontaktTab) {
|
if (kontaktTab) {
|
||||||
kontaktTab.addEventListener('shown.bs.tab', () => {
|
kontaktTab.addEventListener('shown.bs.tab', () => {
|
||||||
@ -1266,6 +1369,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linksTab = document.querySelector('a[href="#links"]');
|
||||||
|
if (linksTab) {
|
||||||
|
linksTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerLinks();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
// Load activity when tab is shown
|
// Load activity when tab is shown
|
||||||
const activityTab = document.querySelector('a[href="#activity"]');
|
const activityTab = document.querySelector('a[href="#activity"]');
|
||||||
if (activityTab) {
|
if (activityTab) {
|
||||||
@ -2315,6 +2425,107 @@ async function loadContacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCustomerCases() {
|
||||||
|
const container = document.getElementById('customerCasesContainer');
|
||||||
|
const empty = document.getElementById('customerCasesEmpty');
|
||||||
|
|
||||||
|
if (!container || !empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag?customer_id=${customerId}`);
|
||||||
|
const cases = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(cases?.detail || 'Kunne ikke hente kundens sager');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.isArray(cases) ? cases : [];
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.map((item) => {
|
||||||
|
const id = Number(item.id) || 0;
|
||||||
|
const title = escapeHtml(item.titel || '-');
|
||||||
|
const statusRaw = String(item.status || 'ukendt');
|
||||||
|
const statusLabel = escapeHtml(statusRaw);
|
||||||
|
const priority = escapeHtml(item.priority || 'normal');
|
||||||
|
const created = item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '-';
|
||||||
|
|
||||||
|
const statusClass =
|
||||||
|
statusRaw.toLowerCase() === 'lukket' ? 'bg-success-subtle text-success-emphasis' :
|
||||||
|
statusRaw.toLowerCase() === 'afventer' ? 'bg-warning-subtle text-warning-emphasis' :
|
||||||
|
'bg-primary-subtle text-primary-emphasis';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><a href="/sag/${id}" class="fw-semibold text-decoration-none">#${id}</a></td>
|
||||||
|
<td>${title}</td>
|
||||||
|
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
|
||||||
|
<td><span class="badge bg-light text-dark border">${priority}</span></td>
|
||||||
|
<td>${created}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/sag/${id}" title="Åbn sag">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer cases:', error);
|
||||||
|
container.innerHTML = `<div class="alert alert-danger mb-0"><i class="bi bi-exclamation-circle me-2"></i>${escapeHtml(error.message || 'Fejl ved hentning af sager')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let subscriptionsLoaded = false;
|
let subscriptionsLoaded = false;
|
||||||
|
|
||||||
async function loadSubscriptions() {
|
async function loadSubscriptions() {
|
||||||
@ -2376,6 +2587,7 @@ async function loadCustomerPipeline() {
|
|||||||
|
|
||||||
let customerHardware = [];
|
let customerHardware = [];
|
||||||
let hardwareLocationsById = {};
|
let hardwareLocationsById = {};
|
||||||
|
let customerLinks = [];
|
||||||
|
|
||||||
function getHardwareGroupLabel(item, groupBy) {
|
function getHardwareGroupLabel(item, groupBy) {
|
||||||
if (groupBy === 'location') {
|
if (groupBy === 'location') {
|
||||||
@ -2548,6 +2760,109 @@ document.addEventListener('change', (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function renderCustomerLinksTable() {
|
||||||
|
const container = document.getElementById('customerLinksContainer');
|
||||||
|
const empty = document.getElementById('customerLinksEmpty');
|
||||||
|
if (!container || !empty) return;
|
||||||
|
|
||||||
|
if (!customerLinks.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Mål</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${customerLinks.map((link) => {
|
||||||
|
const type = (link.type || 'http').toUpperCase();
|
||||||
|
const target = link.url || link.host || '-';
|
||||||
|
const environment = link.environment || 'prod';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">${escapeHtml(link.name || 'Uden navn')}</td>
|
||||||
|
<td><span class="badge text-bg-secondary">${escapeHtml(type)}</span></td>
|
||||||
|
<td>${escapeHtml(target)}</td>
|
||||||
|
<td>${escapeHtml(environment)}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id=${customerId}">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerLinks() {
|
||||||
|
const container = document.getElementById('customerLinksContainer');
|
||||||
|
const empty = document.getElementById('customerLinksEmpty');
|
||||||
|
if (!container || !empty) return;
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Mål</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4"><div class="spinner-border text-primary"></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/links?customer_id=${customerId}`, {
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders()
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error('Ingen adgang til links. Log ind igen eller tjek links.read permission.');
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('Links-endpoint ikke fundet (modul ikke aktivt eller API ikke genstartet).');
|
||||||
|
}
|
||||||
|
throw new Error('Kunne ikke hente links');
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = await response.json();
|
||||||
|
customerLinks = Array.isArray(links) ? links : [];
|
||||||
|
renderCustomerLinksTable();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer links:', error);
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
empty.textContent = error.message || 'Kunne ikke hente links for kunden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCustomerPipeline(opportunities) {
|
function renderCustomerPipeline(opportunities) {
|
||||||
const tbody = document.getElementById('customerOpportunitiesTable');
|
const tbody = document.getElementById('customerOpportunitiesTable');
|
||||||
if (!opportunities || opportunities.length === 0) {
|
if (!opportunities || opportunities.length === 0) {
|
||||||
|
|||||||
@ -4,6 +4,53 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
.customers-toolbar {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-slot {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 460px;
|
||||||
|
width: min(46vw, 460px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap .header-search {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.45rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 0;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear.d-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
@ -19,26 +66,56 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lookup-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.customers-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-slot {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
<div class="d-flex justify-content-between align-items-center mb-5 customers-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
<h2 class="fw-bold mb-1">Kunder</h2>
|
||||||
<p class="text-muted mb-0">Administrer dine kunder</p>
|
<p class="text-muted mb-0">Administrer dine kunder</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
<div class="toolbar-search-slot">
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde...">
|
<div class="search-wrap">
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
|
<input type="search" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, kontakt eller e-mail..." autocomplete="off" spellcheck="false">
|
||||||
|
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2">
|
<div class="mb-4 d-flex gap-2">
|
||||||
<button class="filter-btn active">Alle Kunder</button>
|
<button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button>
|
||||||
<button class="filter-btn">Aktive</button>
|
<button class="filter-btn" data-filter="active" type="button">Aktive</button>
|
||||||
<button class="filter-btn">Inaktive</button>
|
<button class="filter-btn" data-filter="inactive" type="button">Inaktive</button>
|
||||||
<button class="filter-btn">VIP</button>
|
<button class="filter-btn" data-filter="vip" type="button">VIP</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
@ -73,55 +150,391 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="createCustomerModalLabel">Opret ny kunde</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<form id="createCustomerForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="createCustomerCvr">CVR</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="createCustomerCvr" placeholder="fx 24256790" inputmode="numeric" maxlength="8">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="lookupCvrBtn">Hent</button>
|
||||||
|
</div>
|
||||||
|
<div class="lookup-status mt-1" id="lookupCvrStatus">Indtast CVR og klik Hent for autofyld.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="createCustomerName">Virksomhedsnavn *</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerName" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerEmail">E-mail</label>
|
||||||
|
<input type="email" class="form-control" id="createCustomerEmail">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerInvoiceEmail">Faktura e-mail</label>
|
||||||
|
<input type="email" class="form-control" id="createCustomerInvoiceEmail">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerPhone">Telefon</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerPhone">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerWebsite">Website</label>
|
||||||
|
<input type="url" class="form-control" id="createCustomerWebsite" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="createCustomerAddress">Adresse</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerAddress">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label" for="createCustomerPostalCode">Postnr.</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerPostalCode">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label" for="createCustomerCity">By</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerCity">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="createCustomerCountry">Land</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerCountry" value="DK">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 d-flex align-items-end">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="createCustomerIsActive" checked>
|
||||||
|
<label class="form-check-label" for="createCustomerIsActive">Kunden er aktiv</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="createCustomerSubmitBtn">
|
||||||
|
<span class="submit-label">Opret kunde</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
let totalCustomers = 0;
|
let totalCustomers = 0;
|
||||||
let searchTerm = '';
|
let searchTerm = '';
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
let currentRequestController = null;
|
||||||
|
let lastLoadedQueryKey = '';
|
||||||
|
let createCustomerModal = null;
|
||||||
|
let activeFilter = 'all';
|
||||||
|
|
||||||
// Load customers on page load
|
// Load customers on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
|
createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
|
||||||
|
|
||||||
// Setup search with debounce
|
// Setup search with debounce
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const clearBtn = document.getElementById('searchClearBtn');
|
||||||
|
|
||||||
|
const triggerSearch = () => {
|
||||||
|
const nextSearchTerm = searchInput.value.trim();
|
||||||
|
if (nextSearchTerm === searchTerm) {
|
||||||
|
toggleClearButton(nextSearchTerm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTerm = nextSearchTerm;
|
||||||
|
toggleClearButton(searchTerm);
|
||||||
|
loadCustomers(1);
|
||||||
|
};
|
||||||
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
|
toggleClearButton(e.target.value.trim());
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
searchTerm = e.target.value;
|
triggerSearch();
|
||||||
loadCustomers(1);
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (!searchInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
if (!searchInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('createCustomerForm').addEventListener('submit', createCustomer);
|
||||||
|
document.getElementById('lookupCvrBtn').addEventListener('click', lookupCvrAndAutofill);
|
||||||
|
document.getElementById('createCustomerCvr').addEventListener('input', onCvrInput);
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const nextFilter = btn.dataset.filter || 'all';
|
||||||
|
if (nextFilter === activeFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeFilter = nextFilter;
|
||||||
|
syncFilterButtons();
|
||||||
|
lastLoadedQueryKey = '';
|
||||||
|
loadCustomers(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('createCustomerModal').addEventListener('hidden.bs.modal', () => {
|
||||||
|
resetCreateCustomerForm();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onCvrInput(e) {
|
||||||
|
const digits = String(e.target.value || '').replace(/\D/g, '').slice(0, 8);
|
||||||
|
e.target.value = digits;
|
||||||
|
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLookupStatus(message, isError = false) {
|
||||||
|
const status = document.getElementById('lookupCvrStatus');
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.toggle('text-danger', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupCvrAndAutofill() {
|
||||||
|
const cvrInput = document.getElementById('createCustomerCvr');
|
||||||
|
const lookupBtn = document.getElementById('lookupCvrBtn');
|
||||||
|
const cvr = String(cvrInput.value || '').replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (cvr.length !== 8) {
|
||||||
|
setLookupStatus('CVR skal være præcis 8 cifre.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupBtn.disabled = true;
|
||||||
|
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
|
||||||
|
setLookupStatus('Henter data fra FirmaAPI...', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/cvr/${cvr}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
setLookupStatus('CVR blev ikke fundet.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
applyCustomerAutofill(data || {});
|
||||||
|
setLookupStatus('CVR-data hentet og felter autofyldt.', false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CVR lookup failed:', error);
|
||||||
|
setLookupStatus(`Kunne ikke hente CVR-data: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
lookupBtn.disabled = false;
|
||||||
|
lookupBtn.textContent = 'Hent';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomerAutofill(data) {
|
||||||
|
if (data.name) document.getElementById('createCustomerName').value = data.name;
|
||||||
|
if (data.email) document.getElementById('createCustomerEmail').value = data.email;
|
||||||
|
if (data.phone) document.getElementById('createCustomerPhone').value = data.phone;
|
||||||
|
if (data.address) document.getElementById('createCustomerAddress').value = data.address;
|
||||||
|
if (data.city) document.getElementById('createCustomerCity').value = data.city;
|
||||||
|
if (data.postal_code || data.zipcode) {
|
||||||
|
document.getElementById('createCustomerPostalCode').value = data.postal_code || data.zipcode;
|
||||||
|
}
|
||||||
|
if (data.country) document.getElementById('createCustomerCountry').value = data.country;
|
||||||
|
if (data.website) document.getElementById('createCustomerWebsite').value = data.website;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateCustomerPayload() {
|
||||||
|
const email = document.getElementById('createCustomerEmail').value.trim();
|
||||||
|
const domain = email.includes('@') ? email.split('@').pop().toLowerCase() : null;
|
||||||
|
|
||||||
|
const cleanValue = (id) => {
|
||||||
|
const value = document.getElementById(id).value.trim();
|
||||||
|
return value || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: document.getElementById('createCustomerName').value.trim(),
|
||||||
|
cvr_number: cleanValue('createCustomerCvr'),
|
||||||
|
email: email || null,
|
||||||
|
email_domain: domain,
|
||||||
|
phone: cleanValue('createCustomerPhone'),
|
||||||
|
address: cleanValue('createCustomerAddress'),
|
||||||
|
city: cleanValue('createCustomerCity'),
|
||||||
|
postal_code: cleanValue('createCustomerPostalCode'),
|
||||||
|
country: cleanValue('createCustomerCountry') || 'DK',
|
||||||
|
website: cleanValue('createCustomerWebsite'),
|
||||||
|
is_active: document.getElementById('createCustomerIsActive').checked,
|
||||||
|
invoice_email: cleanValue('createCustomerInvoiceEmail'),
|
||||||
|
mobile_phone: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCustomer(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('createCustomerSubmitBtn');
|
||||||
|
const submitLabel = submitBtn.querySelector('.submit-label');
|
||||||
|
const payload = buildCreateCustomerPayload();
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
setLookupStatus('Virksomhedsnavn er påkrævet.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitLabel.textContent = 'Opretter...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await response.json();
|
||||||
|
createCustomerModal.hide();
|
||||||
|
searchTerm = '';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
toggleClearButton('');
|
||||||
|
lastLoadedQueryKey = '';
|
||||||
|
await loadCustomers(1);
|
||||||
|
|
||||||
|
if (created && created.id) {
|
||||||
|
window.location.href = `/customers/${created.id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create customer:', error);
|
||||||
|
setLookupStatus(`Oprettelse fejlede: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitLabel.textContent = 'Opret kunde';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreateCustomerForm() {
|
||||||
|
const form = document.getElementById('createCustomerForm');
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('createCustomerCountry').value = 'DK';
|
||||||
|
document.getElementById('createCustomerIsActive').checked = true;
|
||||||
|
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFilterButtons() {
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.filter === activeFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCustomers(page = 1) {
|
async function loadCustomers(page = 1) {
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (currentRequestController) {
|
||||||
|
currentRequestController.abort();
|
||||||
|
}
|
||||||
|
currentRequestController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
|
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
url += `&search=${encodeURIComponent(searchTerm)}`;
|
url += `&search=${encodeURIComponent(searchTerm)}`;
|
||||||
}
|
}
|
||||||
const response = await fetch(url);
|
|
||||||
|
if (activeFilter === 'active') {
|
||||||
|
url += '&is_active=true';
|
||||||
|
} else if (activeFilter === 'inactive') {
|
||||||
|
url += '&is_active=false';
|
||||||
|
} else if (activeFilter === 'vip') {
|
||||||
|
url += '&vip=true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryKey = `${page}|${searchTerm}|${activeFilter}`;
|
||||||
|
if (queryKey === lastLoadedQueryKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal: currentRequestController.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
lastLoadedQueryKey = queryKey;
|
||||||
totalCustomers = data.total;
|
totalCustomers = data.total;
|
||||||
renderCustomers(data.customers);
|
renderCustomers(data.customers);
|
||||||
renderPagination();
|
renderPagination();
|
||||||
updateCount();
|
updateCount();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Error loading customers:', error);
|
console.error('Error loading customers:', error);
|
||||||
document.getElementById('customersTableBody').innerHTML = `
|
document.getElementById('customersTableBody').innerHTML = `
|
||||||
<tr><td colspan="6" class="text-center text-danger py-5">
|
<tr><td colspan="6" class="text-center text-danger py-5">
|
||||||
❌ Fejl ved indlæsning: ${error.message}
|
❌ Fejl ved indlæsning: ${error.message}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
`;
|
`;
|
||||||
|
} finally {
|
||||||
|
currentRequestController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleClearButton(value) {
|
||||||
|
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
function renderCustomers(customers) {
|
function renderCustomers(customers) {
|
||||||
const tbody = document.getElementById('customersTableBody');
|
const tbody = document.getElementById('customersTableBody');
|
||||||
|
|
||||||
@ -139,6 +552,13 @@ function renderCustomers(customers) {
|
|||||||
const statusBadge = customer.is_active ?
|
const statusBadge = customer.is_active ?
|
||||||
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
|
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
|
||||||
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
||||||
|
const safeInitials = escapeHtml(initials);
|
||||||
|
const safeName = escapeHtml(customer.name);
|
||||||
|
const safeAddress = escapeHtml(customer.address);
|
||||||
|
const safeContactName = escapeHtml(customer.contact_name);
|
||||||
|
const safeContactPhone = escapeHtml(customer.contact_phone);
|
||||||
|
const safeCvr = escapeHtml(customer.cvr_number);
|
||||||
|
const safeEmail = escapeHtml(customer.email);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
|
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
|
||||||
@ -146,21 +566,21 @@ function renderCustomers(customers) {
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
|
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
|
||||||
style="width: 40px; height: 40px; color: var(--accent);">
|
style="width: 40px; height: 40px; color: var(--accent);">
|
||||||
${initials}
|
${safeInitials}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold">${customer.name || '-'}</div>
|
<div class="fw-bold">${safeName}</div>
|
||||||
<div class="small text-muted">${customer.address || '-'}</div>
|
<div class="small text-muted">${safeAddress}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-medium">${customer.contact_name || '-'}</div>
|
<div class="fw-medium">${safeContactName}</div>
|
||||||
<div class="small text-muted">${customer.contact_phone || '-'}</div>
|
<div class="small text-muted">${safeContactPhone}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">${customer.cvr_number || '-'}</td>
|
<td class="text-muted">${safeCvr}</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td class="text-muted">${customer.email || '-'}</td>
|
<td class="text-muted">${safeEmail}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
|
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
|
||||||
@ -236,6 +656,11 @@ function renderPagination() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
|
if (totalCustomers === 0) {
|
||||||
|
document.getElementById('customerCount').textContent = 'Ingen kunder fundet';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const start = (currentPage - 1) * pageSize + 1;
|
const start = (currentPage - 1) * pageSize + 1;
|
||||||
const end = Math.min(currentPage * pageSize, totalCustomers);
|
const end = Math.min(currentPage * pageSize, totalCustomers);
|
||||||
document.getElementById('customerCount').textContent =
|
document.getElementById('customerCount').textContent =
|
||||||
|
|||||||
@ -107,6 +107,21 @@ class MissionService:
|
|||||||
FROM contacts c
|
FROM contacts c
|
||||||
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
||||||
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
||||||
|
ORDER BY (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sag_kontakter sk
|
||||||
|
JOIN sag_sager s ON s.id = sk.sag_id
|
||||||
|
WHERE sk.contact_id = c.id
|
||||||
|
AND sk.deleted_at IS NULL
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) <> 'lukket'
|
||||||
|
) DESC,
|
||||||
|
(
|
||||||
|
SELECT MAX(t.started_at)
|
||||||
|
FROM telefoni_opkald t
|
||||||
|
WHERE t.kontakt_id = c.id
|
||||||
|
) DESC NULLS LAST,
|
||||||
|
c.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
row = execute_query_single(query, (caller_number, caller_number))
|
row = execute_query_single(query, (caller_number, caller_number))
|
||||||
@ -282,7 +297,7 @@ class MissionService:
|
|||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
|
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE LOWER(COALESCE(s.priority, ''))
|
CASE LOWER(COALESCE(s.priority::text, ''))
|
||||||
WHEN 'kritisk' THEN 5
|
WHEN 'kritisk' THEN 5
|
||||||
WHEN 'critical' THEN 5
|
WHEN 'critical' THEN 5
|
||||||
WHEN 'høj' THEN 4
|
WHEN 'høj' THEN 4
|
||||||
|
|||||||
@ -934,17 +934,43 @@
|
|||||||
if (!AudioCtx) return;
|
if (!AudioCtx) return;
|
||||||
|
|
||||||
const context = new AudioCtx();
|
const context = new AudioCtx();
|
||||||
const oscillator = context.createOscillator();
|
const now = context.currentTime;
|
||||||
const gainNode = context.createGain();
|
const isAlert = type === 'uptime_down';
|
||||||
|
const baseFreq = isAlert ? 392 : 784;
|
||||||
|
const overtoneFreq = isAlert ? 523.25 : 1046.5;
|
||||||
|
const totalDuration = isAlert ? 0.9 : 0.65;
|
||||||
|
const strikeDelay = isAlert ? 0.2 : 0.14;
|
||||||
|
|
||||||
oscillator.type = 'sine';
|
function strike(startAt, ampScale) {
|
||||||
oscillator.frequency.value = type === 'uptime_down' ? 260 : 620;
|
const fundamental = context.createOscillator();
|
||||||
gainNode.gain.value = gainValue * 0.2;
|
const overtone = context.createOscillator();
|
||||||
|
const gainNode = context.createGain();
|
||||||
|
|
||||||
oscillator.connect(gainNode);
|
fundamental.type = 'sine';
|
||||||
gainNode.connect(context.destination);
|
overtone.type = 'triangle';
|
||||||
oscillator.start();
|
fundamental.frequency.setValueAtTime(baseFreq, startAt);
|
||||||
oscillator.stop(context.currentTime + (type === 'uptime_down' ? 0.35 : 0.15));
|
overtone.frequency.setValueAtTime(overtoneFreq, startAt);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.0001, startAt);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(Math.max(0.0002, gainValue * ampScale), startAt + 0.01);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.0001, startAt + totalDuration);
|
||||||
|
|
||||||
|
fundamental.connect(gainNode);
|
||||||
|
overtone.connect(gainNode);
|
||||||
|
gainNode.connect(context.destination);
|
||||||
|
|
||||||
|
fundamental.start(startAt);
|
||||||
|
overtone.start(startAt);
|
||||||
|
fundamental.stop(startAt + totalDuration);
|
||||||
|
overtone.stop(startAt + totalDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
strike(now, isAlert ? 0.22 : 0.18);
|
||||||
|
strike(now + strikeDelay, isAlert ? 0.16 : 0.12);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
context.close().catch(() => {});
|
||||||
|
}, Math.ceil((totalDuration + strikeDelay + 0.1) * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateView(viewKey) {
|
function activateView(viewKey) {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ API endpoints for email viewing, classification, and rule management
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
@ -164,6 +164,45 @@ class CreateSagFromEmailRequest(BaseModel):
|
|||||||
priority: Optional[str] = None
|
priority: Optional[str] = None
|
||||||
ansvarlig_bruger_id: Optional[int] = None
|
ansvarlig_bruger_id: Optional[int] = None
|
||||||
assigned_group_id: Optional[int] = None
|
assigned_group_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailReadStateUpdate(BaseModel):
|
||||||
|
is_read: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _can_user_mark_case_email_read(user_id: Optional[int], linked_case_id: Optional[int]) -> bool:
|
||||||
|
"""Allow read-marking only for assignee user or assignee group members."""
|
||||||
|
if not linked_case_id:
|
||||||
|
# Non-case emails can still be marked read.
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT ansvarlig_bruger_id, assigned_group_id
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE id = %s AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(linked_case_id,),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
assigned_user_id = case_row.get("ansvarlig_bruger_id")
|
||||||
|
assigned_group_id = case_row.get("assigned_group_id")
|
||||||
|
|
||||||
|
if assigned_user_id is not None and int(assigned_user_id) == int(user_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if assigned_group_id is not None:
|
||||||
|
user_group = execute_query_single(
|
||||||
|
"SELECT 1 FROM user_groups WHERE user_id = %s AND group_id = %s LIMIT 1",
|
||||||
|
(user_id, assigned_group_id),
|
||||||
|
)
|
||||||
|
if user_group:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
created_by_user_id: int = 1
|
created_by_user_id: int = 1
|
||||||
relation_type: str = "mail"
|
relation_type: str = "mail"
|
||||||
|
|
||||||
@ -369,7 +408,7 @@ async def list_emails(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/emails/{email_id:int}", response_model=EmailDetail)
|
@router.get("/emails/{email_id:int}", response_model=EmailDetail)
|
||||||
async def get_email(email_id: int):
|
async def get_email(email_id: int, request: Request):
|
||||||
"""Get email detail by ID"""
|
"""Get email detail by ID"""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
@ -397,9 +436,14 @@ async def get_email(email_id: int):
|
|||||||
attachments = execute_query(att_query, (email_id,))
|
attachments = execute_query(att_query, (email_id,))
|
||||||
email_data['attachments'] = attachments or []
|
email_data['attachments'] = attachments or []
|
||||||
|
|
||||||
# Mark as read
|
user_id = getattr(request.state, "user_id", None)
|
||||||
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
|
linked_case_id = email_data.get("linked_case_id")
|
||||||
execute_update(update_query, (email_id,))
|
can_mark_read = _can_user_mark_case_email_read(user_id, linked_case_id)
|
||||||
|
|
||||||
|
if not bool(email_data.get("is_read")) and can_mark_read:
|
||||||
|
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
|
||||||
|
execute_update(update_query, (email_id,))
|
||||||
|
email_data["is_read"] = True
|
||||||
|
|
||||||
return email_data
|
return email_data
|
||||||
|
|
||||||
@ -410,6 +454,38 @@ async def get_email(email_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/emails/{email_id:int}/read-state")
|
||||||
|
async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate, request: Request):
|
||||||
|
"""Toggle read/unread state for an email.
|
||||||
|
|
||||||
|
Marking as read on case-linked emails is restricted to case assignee user/group.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT id, linked_case_id, is_read FROM email_messages WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(email_id,),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if payload.is_read:
|
||||||
|
can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id"))
|
||||||
|
if not can_mark_read:
|
||||||
|
raise HTTPException(status_code=403, detail="Email kan ikke markeres som laest: sag er ikke tildelt dig/din gruppe")
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"UPDATE email_messages SET is_read = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(payload.is_read, email_id),
|
||||||
|
)
|
||||||
|
return {"success": True, "email_id": email_id, "is_read": payload.is_read}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating read-state for email {email_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/emails/{email_id}/mark-processed")
|
@router.post("/emails/{email_id}/mark-processed")
|
||||||
async def mark_email_processed(email_id: int):
|
async def mark_email_processed(email_id: int):
|
||||||
"""Mark email as processed and move to 'Processed' folder"""
|
"""Mark email as processed and move to 'Processed' folder"""
|
||||||
|
|||||||
@ -77,7 +77,7 @@ async def _process_reminder_queue():
|
|||||||
# Get assigned user name
|
# Get assigned user name
|
||||||
assigned_user = None
|
assigned_user = None
|
||||||
if event['ansvarlig_bruger_id']:
|
if event['ansvarlig_bruger_id']:
|
||||||
user_query = "SELECT full_name FROM users WHERE id = %s"
|
user_query = "SELECT full_name FROM users WHERE user_id = %s"
|
||||||
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
|
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
|
||||||
assigned_user = user[0]['full_name'] if user else None
|
assigned_user = user[0]['full_name'] if user else None
|
||||||
|
|
||||||
@ -174,7 +174,7 @@ async def _process_time_based_reminders():
|
|||||||
# Get assigned user name
|
# Get assigned user name
|
||||||
assigned_user = None
|
assigned_user = None
|
||||||
if reminder['ansvarlig_bruger_id']:
|
if reminder['ansvarlig_bruger_id']:
|
||||||
user_query = "SELECT full_name FROM users WHERE id = %s"
|
user_query = "SELECT full_name FROM users WHERE user_id = %s"
|
||||||
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
|
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
|
||||||
assigned_user = user[0]['full_name'] if user else None
|
assigned_user = user[0]['full_name'] if user else None
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,35 @@ def _extract_full_name(payload: Any) -> Optional[str]:
|
|||||||
return None
|
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:
|
def _detect_asset_type(payload: Any) -> str:
|
||||||
device_type = _extract_first_str(payload, ["deviceType", "type"])
|
device_type = _extract_first_str(payload, ["deviceType", "type"])
|
||||||
if device_type:
|
if device_type:
|
||||||
@ -104,6 +133,57 @@ def _match_contact(full_name: str, company: str) -> Optional[int]:
|
|||||||
return None
|
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]:
|
def _get_contact_customer(contact_id: int) -> Optional[int]:
|
||||||
query = """
|
query = """
|
||||||
SELECT customer_id
|
SELECT customer_id
|
||||||
@ -213,7 +293,14 @@ async def sync_eset_hardware() -> None:
|
|||||||
|
|
||||||
full_name = _extract_full_name(details)
|
full_name = _extract_full_name(details)
|
||||||
company = _extract_company(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
|
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
|
customer_id = _get_contact_customer(contact_id) if contact_id else None
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None
|
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
|
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:
|
def _eset_detect_asset_type(payload: dict) -> str:
|
||||||
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
|
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
|
||||||
if device_type:
|
if device_type:
|
||||||
@ -89,6 +173,23 @@ def _get_contact_customer(contact_id: int) -> Optional[int]:
|
|||||||
return None
|
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:
|
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
|
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,))
|
result_new = execute_query(query_new, (contact_id,))
|
||||||
|
|
||||||
# Also check legacy hardware table via customer_id (if contact has companies)
|
# Also look up hardware_assets by the contact's company (customer link)
|
||||||
query_legacy = """
|
query_by_customer = """
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
h.id,
|
h.id,
|
||||||
NULL as asset_type,
|
h.asset_type,
|
||||||
NULL as brand,
|
h.brand,
|
||||||
h.model,
|
h.model,
|
||||||
h.serial_number,
|
h.serial_number,
|
||||||
NULL as anydesk_id,
|
h.anydesk_id,
|
||||||
NULL as anydesk_link,
|
h.anydesk_link,
|
||||||
'active' as status,
|
h.status,
|
||||||
NULL as notes,
|
h.notes,
|
||||||
h.created_at,
|
h.created_at,
|
||||||
'hardware' as source_table
|
'hardware_assets' as source_table
|
||||||
FROM hardware h
|
FROM hardware_assets h
|
||||||
WHERE h.customer_id IN (
|
WHERE h.current_owner_customer_id IN (
|
||||||
SELECT customer_id
|
SELECT customer_id
|
||||||
FROM contact_companies
|
FROM contact_companies
|
||||||
WHERE contact_id = %s
|
WHERE contact_id = %s
|
||||||
@ -195,10 +296,15 @@ async def list_hardware_by_contact(contact_id: int):
|
|||||||
AND h.deleted_at IS NULL
|
AND h.deleted_at IS NULL
|
||||||
ORDER BY h.created_at DESC
|
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
|
# Merge: hardware_contacts first (direct link), then customer-linked, dedup by id
|
||||||
all_results = (result_new or []) + (result_legacy or [])
|
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
|
return all_results
|
||||||
|
|
||||||
@ -828,6 +934,60 @@ async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
|
|||||||
return details
|
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)
|
@router.get("/hardware/eset/devices", response_model=dict)
|
||||||
async def list_eset_devices(
|
async def list_eset_devices(
|
||||||
page_size: Optional[int] = Query(None, ge=1, le=1000),
|
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_path = _eset_extract_group_path(details)
|
||||||
group_name = _eset_extract_group_name(details)
|
group_name = _eset_extract_group_name(details)
|
||||||
company = _eset_extract_company(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:
|
if contact_id:
|
||||||
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||||
if not contact_check:
|
if not contact_check:
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
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
|
customer_id = _get_contact_customer(contact_id) if contact_id else None
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
customer_id = _match_customer_exact(group_name or company)
|
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="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="status-pill" id="deviceStatus">Ingen data indlaest</div>
|
||||||
<div class="d-flex gap-2">
|
<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-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
|
||||||
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
|
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="onePcTestStatus" class="contact-muted mb-3"></div>
|
||||||
<div class="table-responsive devices-table">
|
<div class="table-responsive devices-table">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Navn</th>
|
<th>Navn</th>
|
||||||
|
<th>Bruger/ID</th>
|
||||||
<th>Serial</th>
|
<th>Serial</th>
|
||||||
<th>Gruppe</th>
|
<th>Gruppe</th>
|
||||||
<th>Device UUID</th>
|
<th>Device UUID</th>
|
||||||
@ -186,7 +189,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="devicesTable">
|
<tbody id="devicesTable">
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -279,9 +282,42 @@
|
|||||||
return '';
|
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) {
|
function renderDevices(devices) {
|
||||||
if (!devices.length) {
|
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) {
|
if (devicesCards) {
|
||||||
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
|
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
|
||||||
}
|
}
|
||||||
@ -291,12 +327,14 @@
|
|||||||
devicesTable.innerHTML = devices.map(device => {
|
devicesTable.innerHTML = devices.map(device => {
|
||||||
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
|
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
|
||||||
const name = getField(device, ['displayName', 'deviceName', 'name']);
|
const name = getField(device, ['displayName', 'deviceName', 'name']);
|
||||||
|
const login = getUserIdentifier(device);
|
||||||
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
|
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
|
||||||
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
|
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${name || '-'}</td>
|
<td>${name || '-'}</td>
|
||||||
|
<td>${login || '-'}</td>
|
||||||
<td>${serial || '-'}</td>
|
<td>${serial || '-'}</td>
|
||||||
<td>${group || '-'}</td>
|
<td>${group || '-'}</td>
|
||||||
<td class="device-uuid">${uuid || '-'}</td>
|
<td class="device-uuid">${uuid || '-'}</td>
|
||||||
@ -311,15 +349,18 @@
|
|||||||
devicesCards.innerHTML = devices.map((device, index) => {
|
devicesCards.innerHTML = devices.map((device, index) => {
|
||||||
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
|
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
|
||||||
const name = getField(device, ['displayName', 'deviceName', 'name']);
|
const name = getField(device, ['displayName', 'deviceName', 'name']);
|
||||||
|
const login = getUserIdentifier(device);
|
||||||
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
|
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
|
||||||
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
|
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
|
||||||
const safeName = name || '-';
|
const safeName = name || '-';
|
||||||
|
const safeLogin = login || '-';
|
||||||
const safeSerial = serial || '-';
|
const safeSerial = serial || '-';
|
||||||
const safeGroup = group || '-';
|
const safeGroup = group || '-';
|
||||||
const safeUuid = uuid || '';
|
const safeUuid = uuid || '';
|
||||||
return `
|
return `
|
||||||
<div class="device-card" data-index="${index}" data-uuid="${safeUuid}">
|
<div class="device-card" data-index="${index}" data-uuid="${safeUuid}">
|
||||||
<div class="device-card-title">${safeName}</div>
|
<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">Serial: ${safeSerial}</div>
|
||||||
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
|
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
|
||||||
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
|
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
|
||||||
@ -481,7 +522,30 @@
|
|||||||
renderDevices(allDevices);
|
renderDevices(allDevices);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deviceStatus.textContent = 'Fejl ved hentning';
|
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
354
app/modules/links/backend/router.py
Normal file
354
app/modules/links/backend/router.py
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from app.core.auth_dependencies import get_current_user, require_permission
|
||||||
|
from app.core.database import execute_query
|
||||||
|
from app.modules.links.backend.service import (
|
||||||
|
build_action_result,
|
||||||
|
get_link_category_ids,
|
||||||
|
get_relevant_links,
|
||||||
|
log_access,
|
||||||
|
update_link_categories,
|
||||||
|
)
|
||||||
|
from app.modules.links.models.schemas import (
|
||||||
|
Link,
|
||||||
|
LinkActionLogCreate,
|
||||||
|
LinkActionResult,
|
||||||
|
LinkCategory,
|
||||||
|
LinkCategoryCreate,
|
||||||
|
LinkCreate,
|
||||||
|
LinkLatestStatus,
|
||||||
|
LinkVaultResolveRequest,
|
||||||
|
LinkVaultResolveResponse,
|
||||||
|
LinkUpdate,
|
||||||
|
RelevantLink,
|
||||||
|
)
|
||||||
|
from app.services.vaultwarden_service import resolve_vault_credentials
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _with_categories(link_row: dict) -> dict:
|
||||||
|
out = dict(link_row)
|
||||||
|
out["vault_item_ids"] = out.get("vault_item_ids") or []
|
||||||
|
out["category_ids"] = get_link_category_ids(int(out["id"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links/health")
|
||||||
|
async def links_health():
|
||||||
|
execute_query("SELECT 1", ())
|
||||||
|
return {"status": "healthy", "service": "links-module"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links/categories", response_model=List[LinkCategory])
|
||||||
|
async def list_categories(current_user: dict = Depends(require_permission("links.read"))):
|
||||||
|
del current_user
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT * FROM link_categories ORDER BY sort_order ASC, name ASC",
|
||||||
|
(),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links/categories", response_model=LinkCategory)
|
||||||
|
async def create_category(
|
||||||
|
payload: LinkCategoryCreate,
|
||||||
|
current_user: dict = Depends(require_permission("links.create")),
|
||||||
|
):
|
||||||
|
del current_user
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO link_categories (name, icon, sort_order)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(payload.name, payload.icon, payload.sort_order),
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links", response_model=List[Link])
|
||||||
|
async def list_links(
|
||||||
|
q: Optional[str] = Query(None),
|
||||||
|
customer_id: Optional[int] = Query(None),
|
||||||
|
case_id: Optional[int] = Query(None),
|
||||||
|
hardware_id: Optional[int] = Query(None),
|
||||||
|
category_id: Optional[int] = Query(None),
|
||||||
|
is_favorite: Optional[bool] = Query(None),
|
||||||
|
current_user: dict = Depends(require_permission("links.read")),
|
||||||
|
):
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT l.*
|
||||||
|
FROM links l
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
params: List[object] = []
|
||||||
|
|
||||||
|
if q:
|
||||||
|
query += " AND (l.name ILIKE %s OR l.url ILIKE %s OR l.host ILIKE %s)"
|
||||||
|
term = f"%{q}%"
|
||||||
|
params.extend([term, term, term])
|
||||||
|
if customer_id is not None:
|
||||||
|
query += " AND l.customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
if case_id is not None:
|
||||||
|
query += " AND l.case_id = %s"
|
||||||
|
params.append(case_id)
|
||||||
|
if hardware_id is not None:
|
||||||
|
query += " AND l.hardware_id = %s"
|
||||||
|
params.append(hardware_id)
|
||||||
|
if is_favorite is not None:
|
||||||
|
query += " AND l.is_favorite = %s"
|
||||||
|
params.append(is_favorite)
|
||||||
|
if category_id is not None:
|
||||||
|
query += " AND EXISTS (SELECT 1 FROM link_category_map lcm WHERE lcm.link_id = l.id AND lcm.category_id = %s)"
|
||||||
|
params.append(category_id)
|
||||||
|
|
||||||
|
query += " ORDER BY l.is_critical DESC, l.updated_at DESC"
|
||||||
|
rows = execute_query(query, tuple(params) if params else ()) or []
|
||||||
|
|
||||||
|
return [_with_categories(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links/status/latest", response_model=List[LinkLatestStatus])
|
||||||
|
async def list_latest_link_status(
|
||||||
|
link_id: Optional[int] = Query(None),
|
||||||
|
current_user: dict = Depends(require_permission("links.read")),
|
||||||
|
):
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (ls.link_id)
|
||||||
|
ls.link_id,
|
||||||
|
ls.status,
|
||||||
|
ls.checked_at,
|
||||||
|
ls.details
|
||||||
|
FROM link_status_checks ls
|
||||||
|
WHERE (%s IS NULL OR ls.link_id = %s)
|
||||||
|
ORDER BY ls.link_id, ls.checked_at DESC
|
||||||
|
""",
|
||||||
|
(link_id, link_id),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links/{link_id}", response_model=Link)
|
||||||
|
async def get_link(link_id: int, current_user: dict = Depends(require_permission("links.read"))):
|
||||||
|
del current_user
|
||||||
|
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
return _with_categories(rows[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links", response_model=Link)
|
||||||
|
async def create_link(payload: LinkCreate, current_user: dict = Depends(require_permission("links.create"))):
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO links (
|
||||||
|
name, description, type, url, host, port, username, icon, color,
|
||||||
|
customer_id, case_id, hardware_id,
|
||||||
|
vault_item_id, vault_item_ids,
|
||||||
|
is_critical, is_favorite, environment
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload.name,
|
||||||
|
payload.description,
|
||||||
|
payload.type.value,
|
||||||
|
payload.url,
|
||||||
|
payload.host,
|
||||||
|
payload.port,
|
||||||
|
payload.username,
|
||||||
|
payload.icon,
|
||||||
|
payload.color,
|
||||||
|
payload.customer_id,
|
||||||
|
payload.case_id,
|
||||||
|
payload.hardware_id,
|
||||||
|
payload.vault_item_id,
|
||||||
|
json.dumps(payload.vault_item_ids),
|
||||||
|
payload.is_critical,
|
||||||
|
payload.is_favorite,
|
||||||
|
payload.environment.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = rows[0]
|
||||||
|
|
||||||
|
update_link_categories(int(created["id"]), payload.category_ids)
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes)
|
||||||
|
VALUES (%s, %s, %s, %s::jsonb)
|
||||||
|
""",
|
||||||
|
(created["id"], "created", current_user["id"], json.dumps({"name": payload.name})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_categories(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/links/{link_id}", response_model=Link)
|
||||||
|
async def update_link(
|
||||||
|
link_id: int,
|
||||||
|
payload: LinkUpdate,
|
||||||
|
current_user: dict = Depends(require_permission("links.update")),
|
||||||
|
):
|
||||||
|
fields = payload.model_dump(exclude_unset=True)
|
||||||
|
category_ids = fields.pop("category_ids", None)
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params: List[object] = []
|
||||||
|
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
if field_name == "type" and value is not None:
|
||||||
|
updates.append("type = %s")
|
||||||
|
params.append(value.value)
|
||||||
|
elif field_name == "environment" and value is not None:
|
||||||
|
updates.append("environment = %s")
|
||||||
|
params.append(value.value)
|
||||||
|
elif field_name == "vault_item_ids" and value is not None:
|
||||||
|
updates.append("vault_item_ids = %s::jsonb")
|
||||||
|
params.append(json.dumps(value))
|
||||||
|
else:
|
||||||
|
updates.append(f"{field_name} = %s")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.append(link_id)
|
||||||
|
query = f"UPDATE links SET {', '.join(updates)} WHERE id = %s AND deleted_at IS NULL RETURNING *"
|
||||||
|
rows = execute_query(query, tuple(params)) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
updated = rows[0]
|
||||||
|
else:
|
||||||
|
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
updated = rows[0]
|
||||||
|
|
||||||
|
if category_ids is not None:
|
||||||
|
update_link_categories(link_id, category_ids)
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes)
|
||||||
|
VALUES (%s, %s, %s, %s::jsonb)
|
||||||
|
""",
|
||||||
|
(link_id, "updated", current_user["id"], json.dumps(fields or {"category_ids": category_ids})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_categories(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/links/{link_id}")
|
||||||
|
async def delete_link(link_id: int, current_user: dict = Depends(require_permission("links.delete"))):
|
||||||
|
rows = execute_query(
|
||||||
|
"UPDATE links SET deleted_at = NOW(), updated_at = NOW() WHERE id = %s AND deleted_at IS NULL RETURNING id",
|
||||||
|
(link_id,),
|
||||||
|
) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO links_audit_log (link_id, event_type, actor_user_id, changes)
|
||||||
|
VALUES (%s, %s, %s, %s::jsonb)
|
||||||
|
""",
|
||||||
|
(link_id, "deleted", current_user["id"], json.dumps({"deleted": True})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "deleted", "id": link_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links/cases/{case_id}/relevant", response_model=List[RelevantLink])
|
||||||
|
async def case_relevant_links(
|
||||||
|
case_id: int,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: dict = Depends(require_permission("links.read")),
|
||||||
|
):
|
||||||
|
del current_user
|
||||||
|
return get_relevant_links(case_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links/{link_id}/access", response_model=LinkActionResult)
|
||||||
|
async def access_link(
|
||||||
|
link_id: int,
|
||||||
|
payload: LinkActionLogCreate,
|
||||||
|
current_user: dict = Depends(require_permission("links.use")),
|
||||||
|
):
|
||||||
|
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
|
||||||
|
link_row = rows[0]
|
||||||
|
action_result = build_action_result(link_row, payload.action_type)
|
||||||
|
|
||||||
|
log_access(
|
||||||
|
link_id=link_id,
|
||||||
|
user_id=current_user["id"],
|
||||||
|
action_type=payload.action_type,
|
||||||
|
case_id=payload.case_id,
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
return action_result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links/{link_id}/vault/resolve", response_model=LinkVaultResolveResponse)
|
||||||
|
async def resolve_link_vault(
|
||||||
|
link_id: int,
|
||||||
|
payload: LinkVaultResolveRequest,
|
||||||
|
current_user: dict = Depends(require_permission("links.use")),
|
||||||
|
):
|
||||||
|
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
|
||||||
|
link_row = rows[0]
|
||||||
|
fallback_item_ids = link_row.get("vault_item_ids") or []
|
||||||
|
if not isinstance(fallback_item_ids, list):
|
||||||
|
fallback_item_ids = []
|
||||||
|
|
||||||
|
result = await resolve_vault_credentials(
|
||||||
|
preferred_item_id=payload.item_id or link_row.get("vault_item_id"),
|
||||||
|
fallback_item_ids=[str(item) for item in fallback_item_ids if item],
|
||||||
|
search_hint=payload.search_hint or link_row.get("host") or link_row.get("url") or link_row.get("name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
log_access(
|
||||||
|
link_id=link_id,
|
||||||
|
user_id=current_user["id"],
|
||||||
|
action_type="vault.resolve",
|
||||||
|
case_id=link_row.get("case_id"),
|
||||||
|
customer_id=link_row.get("customer_id"),
|
||||||
|
metadata={
|
||||||
|
"status": result.get("status"),
|
||||||
|
"configured": result.get("configured"),
|
||||||
|
"checked_item_ids": result.get("checked_item_ids") or [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links/health/run")
|
||||||
|
async def run_links_health_check(
|
||||||
|
current_user: dict = Depends(require_permission("links.diagnose")),
|
||||||
|
):
|
||||||
|
del current_user
|
||||||
|
from app.modules.links.jobs.dead_link_check import check_links_health
|
||||||
|
|
||||||
|
result = await check_links_health()
|
||||||
|
return {"status": "ok", "result": result}
|
||||||
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
143
app/modules/links/jobs/dead_link_check.py
Normal file
143
app/modules/links/jobs/dead_link_check.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_http_url(url: Optional[str], host: Optional[str]) -> Optional[str]:
|
||||||
|
candidate = (url or "").strip()
|
||||||
|
if not candidate and host:
|
||||||
|
candidate = host.strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||||
|
return candidate
|
||||||
|
return f"http://{candidate}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_http(client: httpx.AsyncClient, url: str) -> Tuple[str, dict]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await client.get(url)
|
||||||
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
status = "ok" if response.status_code < 400 else "down"
|
||||||
|
return status, {
|
||||||
|
"checker": "http",
|
||||||
|
"url": str(response.url),
|
||||||
|
"http_status": response.status_code,
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
return "down", {
|
||||||
|
"checker": "http",
|
||||||
|
"url": url,
|
||||||
|
"error": str(exc),
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_tcp(host: str, port: int, timeout_seconds: int, checker: str) -> Tuple[str, dict]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=float(timeout_seconds))
|
||||||
|
del reader
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
return "ok", {
|
||||||
|
"checker": checker,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
return "down", {
|
||||||
|
"checker": checker,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"error": str(exc),
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_link(row: dict, client: httpx.AsyncClient, timeout_seconds: int) -> Tuple[str, dict]:
|
||||||
|
link_type = row.get("type")
|
||||||
|
host = row.get("host")
|
||||||
|
port = row.get("port")
|
||||||
|
url = row.get("url")
|
||||||
|
|
||||||
|
if link_type == "http":
|
||||||
|
normalized_url = _normalize_http_url(url, host)
|
||||||
|
if not normalized_url:
|
||||||
|
return "unknown", {"checker": "http", "reason": "missing_url_or_host"}
|
||||||
|
return await _check_http(client, normalized_url)
|
||||||
|
|
||||||
|
if link_type == "ssh":
|
||||||
|
if not host:
|
||||||
|
return "unknown", {"checker": "tcp", "reason": "missing_host", "type": "ssh"}
|
||||||
|
return await _check_tcp(host, int(port or 22), timeout_seconds, "tcp-ssh")
|
||||||
|
|
||||||
|
if link_type == "rdp":
|
||||||
|
if not host:
|
||||||
|
return "unknown", {"checker": "tcp", "reason": "missing_host", "type": "rdp"}
|
||||||
|
return await _check_tcp(host, int(port or 3389), timeout_seconds, "tcp-rdp")
|
||||||
|
|
||||||
|
if link_type == "command":
|
||||||
|
return "unknown", {"checker": "command", "reason": "not_probeable"}
|
||||||
|
|
||||||
|
return "unknown", {"checker": "unknown", "reason": f"unsupported_type:{link_type}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_status(link_id: int, status: str, details: dict) -> None:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO link_status_checks (link_id, status, details)
|
||||||
|
VALUES (%s, %s, %s::jsonb)
|
||||||
|
""",
|
||||||
|
(link_id, status, json.dumps(details or {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_links_health():
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT id, type, url, host, port FROM links WHERE deleted_at IS NULL",
|
||||||
|
(),
|
||||||
|
) or []
|
||||||
|
timeout_seconds = max(1, int(settings.LINKS_CHECK_TIMEOUT_SECONDS))
|
||||||
|
|
||||||
|
if settings.LINKS_DRY_RUN:
|
||||||
|
for row in rows:
|
||||||
|
_persist_status(int(row["id"]), "unknown", {"reason": "dry_run_enabled"})
|
||||||
|
logger.info("✅ Links health check skipped by dry-run for %s links", len(rows))
|
||||||
|
return {"checked": len(rows), "ok": 0, "down": 0, "unknown": len(rows), "dry_run": True}
|
||||||
|
|
||||||
|
summary = {"checked": 0, "ok": 0, "down": 0, "unknown": 0, "dry_run": False}
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(connect=float(timeout_seconds), read=float(timeout_seconds), write=float(timeout_seconds), pool=float(timeout_seconds))
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
|
for row in rows:
|
||||||
|
link_id = int(row["id"])
|
||||||
|
status, details = await _evaluate_link(row, client, timeout_seconds)
|
||||||
|
_persist_status(link_id, status, details)
|
||||||
|
|
||||||
|
summary["checked"] += 1
|
||||||
|
summary[status] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ Links health check completed: checked=%s ok=%s down=%s unknown=%s",
|
||||||
|
summary["checked"],
|
||||||
|
summary["ok"],
|
||||||
|
summary["down"],
|
||||||
|
summary["unknown"],
|
||||||
|
)
|
||||||
|
return summary
|
||||||
0
app/modules/links/models/__init__.py
Normal file
0
app/modules/links/models/__init__.py
Normal file
153
app/modules/links/models/schemas.py
Normal file
153
app/modules/links/models/schemas.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LinkType(str, Enum):
|
||||||
|
http = "http"
|
||||||
|
ssh = "ssh"
|
||||||
|
rdp = "rdp"
|
||||||
|
command = "command"
|
||||||
|
|
||||||
|
|
||||||
|
class LinkEnvironment(str, Enum):
|
||||||
|
prod = "prod"
|
||||||
|
test = "test"
|
||||||
|
dev = "dev"
|
||||||
|
|
||||||
|
|
||||||
|
class LinkScope(str, Enum):
|
||||||
|
case = "case"
|
||||||
|
customer = "customer"
|
||||||
|
hardware = "hardware"
|
||||||
|
global_scope = "global"
|
||||||
|
|
||||||
|
|
||||||
|
class LinkCategoryBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
icon: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
sort_order: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class LinkCategoryCreate(LinkCategoryBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LinkCategory(LinkCategoryBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LinkBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
type: LinkType
|
||||||
|
url: Optional[str] = None
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||||
|
username: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
case_id: Optional[int] = None
|
||||||
|
hardware_id: Optional[int] = None
|
||||||
|
vault_item_id: Optional[str] = None
|
||||||
|
vault_item_ids: List[str] = Field(default_factory=list)
|
||||||
|
is_critical: bool = False
|
||||||
|
is_favorite: bool = False
|
||||||
|
environment: LinkEnvironment = LinkEnvironment.prod
|
||||||
|
|
||||||
|
|
||||||
|
class LinkCreate(LinkBase):
|
||||||
|
category_ids: List[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
type: Optional[LinkType] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||||
|
username: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
case_id: Optional[int] = None
|
||||||
|
hardware_id: Optional[int] = None
|
||||||
|
vault_item_id: Optional[str] = None
|
||||||
|
vault_item_ids: Optional[List[str]] = None
|
||||||
|
is_critical: Optional[bool] = None
|
||||||
|
is_favorite: Optional[bool] = None
|
||||||
|
environment: Optional[LinkEnvironment] = None
|
||||||
|
category_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Link(LinkBase):
|
||||||
|
id: int
|
||||||
|
category_ids: List[int] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RelevantLink(Link):
|
||||||
|
scope: LinkScope
|
||||||
|
scope_priority: int
|
||||||
|
score: int
|
||||||
|
match_count: int
|
||||||
|
matched_tag_ids: List[int] = Field(default_factory=list)
|
||||||
|
category_ids: List[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkActionLogCreate(BaseModel):
|
||||||
|
action_type: str = Field(..., min_length=1, max_length=50)
|
||||||
|
case_id: Optional[int] = None
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LinkActionResult(BaseModel):
|
||||||
|
link_id: int
|
||||||
|
action_type: str
|
||||||
|
type: LinkType
|
||||||
|
open_url: Optional[str] = None
|
||||||
|
ssh_command: Optional[str] = None
|
||||||
|
rdp_content: Optional[str] = None
|
||||||
|
command_text: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
vault_item_id: Optional[str] = None
|
||||||
|
vault_search_hint: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LinkLatestStatus(BaseModel):
|
||||||
|
link_id: int
|
||||||
|
status: str
|
||||||
|
checked_at: datetime
|
||||||
|
details: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class VaultCredential(BaseModel):
|
||||||
|
item_id: Optional[str] = None
|
||||||
|
item_name: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
totp: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LinkVaultResolveRequest(BaseModel):
|
||||||
|
item_id: Optional[str] = None
|
||||||
|
search_hint: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LinkVaultResolveResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
configured: bool
|
||||||
|
message: Optional[str] = None
|
||||||
|
checked_item_ids: List[str] = Field(default_factory=list)
|
||||||
|
credential: Optional[VaultCredential] = None
|
||||||
1015
app/modules/links/templates/index.html
Normal file
1015
app/modules/links/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,59 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_api_print_bridge(api_path: str, page_title: str) -> str:
|
||||||
|
safe_api_path = json.dumps(api_path)
|
||||||
|
safe_title = json.dumps(page_title)
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang=\"da\">
|
||||||
|
<head>
|
||||||
|
<meta charset=\"utf-8\" />
|
||||||
|
<title>{page_title}</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: 'Segoe UI', sans-serif; margin: 20px; color: #0f172a; }}
|
||||||
|
.muted {{ color: #475569; }}
|
||||||
|
.error {{ color: #b42318; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id=\"state\" class=\"muted\">Henter printvisning...</div>
|
||||||
|
<script>
|
||||||
|
(async function () {{
|
||||||
|
const apiPath = {safe_api_path};
|
||||||
|
const pageTitle = {safe_title};
|
||||||
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||||
|
const headers = token ? {{ Authorization: `Bearer ${{token}}` }} : {{}};
|
||||||
|
|
||||||
|
try {{
|
||||||
|
const res = await fetch(apiPath, {{ method: 'GET', headers, credentials: 'include' }});
|
||||||
|
if (!res.ok) {{
|
||||||
|
let detail = `Kunne ikke hente printvisning (${{res.status}})`;
|
||||||
|
try {{
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload && payload.detail) detail = payload.detail;
|
||||||
|
}} catch (_) {{}}
|
||||||
|
document.getElementById('state').className = 'error';
|
||||||
|
document.getElementById('state').textContent = detail;
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
document.open();
|
||||||
|
document.write(html);
|
||||||
|
document.close();
|
||||||
|
if (!document.title) document.title = pageTitle;
|
||||||
|
}} catch (error) {{
|
||||||
|
document.getElementById('state').className = 'error';
|
||||||
|
document.getElementById('state').textContent = `Fejl ved hentning af printvisning: ${{error?.message || 'Ukendt fejl'}}`;
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _is_deadline_overdue(deadline_value) -> bool:
|
def _is_deadline_overdue(deadline_value) -> bool:
|
||||||
if not deadline_value:
|
if not deadline_value:
|
||||||
return False
|
return False
|
||||||
@ -128,7 +181,15 @@ async def sager_liste(
|
|||||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
g.name AS assigned_group_name,
|
g.name AS assigned_group_name,
|
||||||
nt.title AS next_todo_title,
|
nt.title AS next_todo_title,
|
||||||
nt.due_date AS next_todo_due_date
|
nt.due_date AS next_todo_due_date,
|
||||||
|
COALESCE(ec.unread_email_count, 0) AS unread_email_count,
|
||||||
|
ec.oldest_unread_received_date,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none'
|
||||||
|
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot'
|
||||||
|
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm'
|
||||||
|
ELSE 'fresh'
|
||||||
|
END AS unread_email_level
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
@ -157,6 +218,14 @@ async def sager_liste(
|
|||||||
t.created_at ASC
|
t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) nt ON true
|
) nt ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count,
|
||||||
|
MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date
|
||||||
|
FROM sag_emails se
|
||||||
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
|
WHERE se.sag_id = s.id
|
||||||
|
) ec ON true
|
||||||
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
|
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
|
||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
@ -196,10 +265,26 @@ async def sager_liste(
|
|||||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
NULL::text AS assigned_group_name,
|
NULL::text AS assigned_group_name,
|
||||||
NULL::text AS next_todo_title,
|
NULL::text AS next_todo_title,
|
||||||
NULL::timestamp AS next_todo_due_date
|
NULL::timestamp AS next_todo_due_date,
|
||||||
|
COALESCE(ec.unread_email_count, 0) AS unread_email_count,
|
||||||
|
ec.oldest_unread_received_date,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none'
|
||||||
|
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot'
|
||||||
|
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm'
|
||||||
|
ELSE 'fresh'
|
||||||
|
END AS unread_email_level
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count,
|
||||||
|
MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date
|
||||||
|
FROM sag_emails se
|
||||||
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
|
WHERE se.sag_id = s.id
|
||||||
|
) ec ON true
|
||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
"""
|
"""
|
||||||
fallback_params = []
|
fallback_params = []
|
||||||
@ -289,6 +374,7 @@ async def sager_liste(
|
|||||||
"toggle_include_deferred_url": toggle_include_deferred_url,
|
"toggle_include_deferred_url": toggle_include_deferred_url,
|
||||||
"assignment_users": _fetch_assignment_users(),
|
"assignment_users": _fetch_assignment_users(),
|
||||||
"assignment_groups": _fetch_assignment_groups(),
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
|
"current_customer_id": customer_id_int,
|
||||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||||
"current_assigned_group_id": assigned_group_id_int,
|
"current_assigned_group_id": assigned_group_id_int,
|
||||||
})
|
})
|
||||||
@ -307,6 +393,7 @@ async def sager_liste(
|
|||||||
"toggle_include_deferred_url": str(request.url),
|
"toggle_include_deferred_url": str(request.url),
|
||||||
"assignment_users": [],
|
"assignment_users": [],
|
||||||
"assignment_groups": [],
|
"assignment_groups": [],
|
||||||
|
"current_customer_id": customer_id_int,
|
||||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||||
"current_assigned_group_id": assigned_group_id_int,
|
"current_assigned_group_id": assigned_group_id_int,
|
||||||
})
|
})
|
||||||
@ -320,6 +407,32 @@ async def opret_sag_side(request: Request):
|
|||||||
"assignment_groups": _fetch_assignment_groups(),
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse)
|
||||||
|
async def sag_work_order_print_page(request: Request, sag_id: int):
|
||||||
|
auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
api_path = f"/api/v1/sag/{sag_id}/work-orders/print"
|
||||||
|
if auto_print:
|
||||||
|
api_path = f"{api_path}?auto_print=1"
|
||||||
|
html = _render_api_print_bridge(
|
||||||
|
api_path=api_path,
|
||||||
|
page_title=f"Arbejdsseddel SAG-{sag_id}",
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/labels/hardware/print", response_class=HTMLResponse)
|
||||||
|
async def sag_hardware_labels_print_page(request: Request, sag_id: int):
|
||||||
|
auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
api_path = f"/api/v1/sag/{sag_id}/labels/hardware/print"
|
||||||
|
if auto_print:
|
||||||
|
api_path = f"{api_path}?auto_print=1"
|
||||||
|
html = _render_api_print_bridge(
|
||||||
|
api_path=api_path,
|
||||||
|
page_title=f"Hardware labels SAG-{sag_id}",
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
|
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
|
||||||
async def sag_varekob_salg(request: Request):
|
async def sag_varekob_salg(request: Request):
|
||||||
"""Display orders overview for all purchases and sales."""
|
"""Display orders overview for all purchases and sales."""
|
||||||
|
|||||||
@ -124,6 +124,18 @@
|
|||||||
[data-bs-theme="dark"] .selected-item button {
|
[data-bs-theme="dark"] .selected-item button {
|
||||||
color: #a6d5fa;
|
color: #a6d5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.case-top-alerts .alert {
|
||||||
|
border-left: 6px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-top-alerts .alert-warning {
|
||||||
|
border-left-color: #f59f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-top-alerts .alert-danger {
|
||||||
|
border-left-color: #e03131;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -139,6 +151,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
|
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
|
<div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
|
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
|
||||||
@ -311,6 +325,79 @@
|
|||||||
let contactSearchTimeout;
|
let contactSearchTimeout;
|
||||||
let successAlertTimeout;
|
let successAlertTimeout;
|
||||||
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
||||||
|
let topAlertLoadToken = 0;
|
||||||
|
|
||||||
|
function escapeTopAlertHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCreateTopAlertsForCustomer(customerId) {
|
||||||
|
const container = document.getElementById('caseTopAlerts');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadToken = ++topAlertLoadToken;
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadToken !== topAlertLoadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
|
||||||
|
|
||||||
|
if (!alerts.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = alerts.map((alert) => {
|
||||||
|
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
|
||||||
|
const klass = isCritical ? 'alert-danger' : 'alert-warning';
|
||||||
|
const label = isCritical ? 'KRITISK' : 'ADVARSEL';
|
||||||
|
const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
|
||||||
|
const message = escapeTopAlertHtml(alert.message || '');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="alert ${klass} mb-2" role="alert">
|
||||||
|
<strong>${label}:</strong> ${title}
|
||||||
|
${message ? `<div class="small mt-1">${message}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
} catch (error) {
|
||||||
|
if (loadToken !== topAlertLoadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Failed to load customer alerts on sag create:', error);
|
||||||
|
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to show success alert
|
// Helper function to show success alert
|
||||||
function showSuccessAlert(message, duration = 3000) {
|
function showSuccessAlert(message, duration = 3000) {
|
||||||
@ -436,6 +523,7 @@
|
|||||||
document.getElementById('customerSearch').value = '';
|
document.getElementById('customerSearch').value = '';
|
||||||
document.getElementById('customerResults').classList.add('d-none');
|
document.getElementById('customerResults').classList.add('d-none');
|
||||||
renderSelections();
|
renderSelections();
|
||||||
|
loadCreateTopAlertsForCustomer(id);
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
if (!skipAlert) {
|
if (!skipAlert) {
|
||||||
@ -447,6 +535,7 @@
|
|||||||
selectedCustomer = null;
|
selectedCustomer = null;
|
||||||
document.getElementById('customer_id').value = '';
|
document.getElementById('customer_id').value = '';
|
||||||
renderSelections();
|
renderSelections();
|
||||||
|
loadCreateTopAlertsForCustomer(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectContact(id, name) {
|
async function selectContact(id, name) {
|
||||||
@ -460,7 +549,7 @@
|
|||||||
|
|
||||||
// Check for associated company (auto-select if single match)
|
// Check for associated company (auto-select if single match)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/contacts/${id}`);
|
const response = await fetch(`/api/v1/contacts/${id}`, { credentials: 'include' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
selectedContactsCompanies[id] = data.companies || [];
|
selectedContactsCompanies[id] = data.companies || [];
|
||||||
@ -530,7 +619,7 @@
|
|||||||
|
|
||||||
if (telefoniPrefill.customerId && !selectedCustomer) {
|
if (telefoniPrefill.customerId && !selectedCustomer) {
|
||||||
try {
|
try {
|
||||||
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`);
|
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`, { credentials: 'include' });
|
||||||
if (customerRes.ok) {
|
if (customerRes.ok) {
|
||||||
const customer = await customerRes.json();
|
const customer = await customerRes.json();
|
||||||
const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`;
|
const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`;
|
||||||
@ -543,7 +632,7 @@
|
|||||||
|
|
||||||
if (telefoniPrefill.contactId) {
|
if (telefoniPrefill.contactId) {
|
||||||
try {
|
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;
|
if (!res.ok) return;
|
||||||
const c = await res.json();
|
const c = await res.json();
|
||||||
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`;
|
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`;
|
||||||
@ -598,7 +687,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const responses = await Promise.all(
|
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 datasets = await Promise.all(responses.map(r => r.ok ? r.json() : []));
|
||||||
const merged = new Map();
|
const merged = new Map();
|
||||||
@ -686,6 +775,7 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/hardware/quick', {
|
const response = await fetch('/api/v1/hardware/quick', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
@ -723,6 +813,7 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/customers', {
|
const response = await fetch('/api/v1/customers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: name.trim() })
|
body: JSON.stringify({ name: name.trim() })
|
||||||
});
|
});
|
||||||
@ -738,6 +829,7 @@
|
|||||||
const linkResponses = await Promise.all(contactIds.map(contactId =>
|
const linkResponses = await Promise.all(contactIds.map(contactId =>
|
||||||
fetch(`/api/v1/contacts/${contactId}/companies`, {
|
fetch(`/api/v1/contacts/${contactId}/companies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ customer_id: created.id, is_primary: false })
|
body: JSON.stringify({ customer_id: created.id, is_primary: false })
|
||||||
})
|
})
|
||||||
@ -772,6 +864,7 @@
|
|||||||
};
|
};
|
||||||
const response = await fetch('/api/v1/contacts', {
|
const response = await fetch('/api/v1/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
@ -886,6 +979,7 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/sag', {
|
const response = await fetch('/api/v1/sag', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
@ -897,9 +991,7 @@
|
|||||||
const contactPromises = Object.keys(selectedContacts).map(cid =>
|
const contactPromises = Object.keys(selectedContacts).map(cid =>
|
||||||
fetch(`/api/v1/sag/${result.id}/contacts`, {
|
fetch(`/api/v1/sag/${result.id}/contacts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
credentials: 'include',
|
||||||
body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'})
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(contactPromises);
|
await Promise.all(contactPromises);
|
||||||
@ -909,6 +1001,7 @@
|
|||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
|
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sag_id: result.id,
|
sag_id: result.id,
|
||||||
@ -925,6 +1018,7 @@
|
|||||||
const linkPromises = Object.keys(selectedContacts).map(cid =>
|
const linkPromises = Object.keys(selectedContacts).map(cid =>
|
||||||
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
|
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
|
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -86,6 +86,41 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sag-unread-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
margin-left: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sag-unread-fresh {
|
||||||
|
background: #2f9e44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sag-unread-warm {
|
||||||
|
background: #f08c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sag-unread-hot {
|
||||||
|
background: #c92a2a;
|
||||||
|
animation: sagUnreadPulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sagUnreadPulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.08); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
.sag-titel {
|
.sag-titel {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -266,11 +301,30 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sag-top-alerts {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sag-top-alerts .alert {
|
||||||
|
border-left: 6px solid;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sag-top-alerts .alert-warning {
|
||||||
|
border-left-color: #f59f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sag-top-alerts .alert-danger {
|
||||||
|
border-left-color: #e03131;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
|
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
|
||||||
|
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="margin: 0; color: var(--accent);">
|
<h1 style="margin: 0; color: var(--accent);">
|
||||||
@ -382,6 +436,12 @@
|
|||||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="sag-id">#{{ sag.id }}</span>
|
<span class="sag-id">#{{ sag.id }}</span>
|
||||||
|
{% if (sag.unread_email_count or 0) > 0 %}
|
||||||
|
{% set unread_level = sag.unread_email_level or 'fresh' %}
|
||||||
|
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
|
||||||
|
{{ sag.unread_email_count if sag.unread_email_count <= 99 else '99+' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||||
@ -440,6 +500,12 @@
|
|||||||
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
|
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
|
||||||
<td>
|
<td>
|
||||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||||
|
{% if (related_sag.unread_email_count or 0) > 0 %}
|
||||||
|
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
|
||||||
|
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
|
||||||
|
{{ related_sag.unread_email_count if related_sag.unread_email_count <= 99 else '99+' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||||
@ -508,6 +574,67 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
|
||||||
|
|
||||||
|
function escapeTopAlertHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSagTopAlertsForCustomer(customerId) {
|
||||||
|
const container = document.getElementById('sagTopAlerts');
|
||||||
|
if (!container || !customerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
|
||||||
|
|
||||||
|
if (!alerts.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = alerts.map((alert) => {
|
||||||
|
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
|
||||||
|
const klass = isCritical ? 'alert-danger' : 'alert-warning';
|
||||||
|
const label = isCritical ? 'KRITISK' : 'ADVARSEL';
|
||||||
|
const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
|
||||||
|
const message = escapeTopAlertHtml(alert.message || '');
|
||||||
|
return `
|
||||||
|
<div class="alert ${klass} mb-2" role="alert">
|
||||||
|
<strong>${label}:</strong> ${title}
|
||||||
|
${message ? `<div class="small mt-1">${message}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer alerts on sag list:', error);
|
||||||
|
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tree toggle functionality
|
// Tree toggle functionality
|
||||||
function toggleTreeNode(event, sagId) {
|
function toggleTreeNode(event, sagId) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -636,5 +763,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadTypeFilters();
|
loadTypeFilters();
|
||||||
|
|
||||||
|
if (topAlertCustomerId) {
|
||||||
|
loadSagTopAlertsForCustomer(topAlertCustomerId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -27,19 +27,23 @@ async def search_contacts(q: str = Query(..., min_length=2)):
|
|||||||
"""
|
"""
|
||||||
Autocomplete search for contacts.
|
Autocomplete search for contacts.
|
||||||
Returns list of {id, first_name, last_name, email}
|
Returns list of {id, first_name, last_name, email}
|
||||||
|
Supports: first name, last name, email, combined "Fornavn Efternavn", phone, mobile.
|
||||||
"""
|
"""
|
||||||
sql = """
|
sql = """
|
||||||
SELECT id, first_name, last_name, email
|
SELECT id, first_name, last_name, email
|
||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE
|
WHERE
|
||||||
(first_name ILIKE %s OR
|
first_name ILIKE %s
|
||||||
last_name ILIKE %s OR
|
OR last_name ILIKE %s
|
||||||
email 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
|
ORDER BY first_name ASC, last_name ASC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
"""
|
"""
|
||||||
term = f"%{q}%"
|
term = f"%{q}%"
|
||||||
results = execute_query(sql, (term, term, term))
|
results = execute_query(sql, (term, term, term, term, term, term))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@router.get("/search/hardware")
|
@router.get("/search/hardware")
|
||||||
|
|||||||
@ -39,8 +39,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
|
|||||||
|
|
||||||
contact_id = payload.contact_id
|
contact_id = payload.contact_id
|
||||||
if not contact_id:
|
if not contact_id:
|
||||||
suffix8 = phone_suffix_8(payload.to)
|
contact = TelefoniService.find_contact_by_phone(payload.to)
|
||||||
contact = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
|
||||||
contact_id = int(contact["id"]) if contact and contact.get("id") else None
|
contact_id = int(contact["id"]) if contact and contact.get("id") else None
|
||||||
|
|
||||||
if not contact_id:
|
if not contact_id:
|
||||||
@ -246,11 +245,10 @@ async def yealink_established(
|
|||||||
break
|
break
|
||||||
ekstern_e164 = normalize_e164(ekstern_raw)
|
ekstern_e164 = normalize_e164(ekstern_raw)
|
||||||
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
|
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)
|
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
|
kontakt_id = kontakt.get("id") if kontakt else None
|
||||||
|
|
||||||
# Get extended contact details if we found a contact
|
# Get extended contact details if we found a contact
|
||||||
@ -665,7 +663,13 @@ async def list_calls(
|
|||||||
t.sag_id,
|
t.sag_id,
|
||||||
t.started_at,
|
t.started_at,
|
||||||
t.ended_at,
|
t.ended_at,
|
||||||
t.duration_sec,
|
COALESCE(
|
||||||
|
t.duration_sec,
|
||||||
|
CASE
|
||||||
|
WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) AS duration_sec,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
u.username,
|
u.username,
|
||||||
u.full_name,
|
u.full_name,
|
||||||
|
|||||||
@ -20,15 +20,29 @@ class TelefoniService:
|
|||||||
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
|
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
|
def find_contact_by_phone(number: Optional[str]) -> Optional[dict]:
|
||||||
if not suffix8:
|
"""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
|
return None
|
||||||
|
|
||||||
query = """
|
_contact_cte = """
|
||||||
SELECT
|
SELECT
|
||||||
c.id,
|
c.id,
|
||||||
c.first_name,
|
c.first_name,
|
||||||
c.last_name,
|
c.last_name,
|
||||||
|
(
|
||||||
|
SELECT cu.id
|
||||||
|
FROM contact_companies cc
|
||||||
|
JOIN customers cu ON cu.id = cc.customer_id
|
||||||
|
WHERE cc.contact_id = c.id
|
||||||
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS company_id,
|
||||||
(
|
(
|
||||||
SELECT cu.name
|
SELECT cu.name
|
||||||
FROM contact_companies cc
|
FROM contact_companies cc
|
||||||
@ -36,22 +50,66 @@ class TelefoniService:
|
|||||||
WHERE cc.contact_id = c.id
|
WHERE cc.contact_id = c.id
|
||||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS company
|
) AS company,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sag_kontakter sk
|
||||||
|
JOIN sag_sager s ON s.id = sk.sag_id
|
||||||
|
WHERE sk.contact_id = c.id
|
||||||
|
AND sk.deleted_at IS NULL
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) <> 'lukket'
|
||||||
|
) AS open_case_count,
|
||||||
|
(
|
||||||
|
SELECT MAX(t.started_at)
|
||||||
|
FROM telefoni_opkald t
|
||||||
|
WHERE t.kontakt_id = c.id
|
||||||
|
) AS last_call_at
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
|
|
||||||
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
|
|
||||||
ORDER BY c.id ASC
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
"""
|
||||||
row = execute_query_single(query, (suffix8, suffix8))
|
|
||||||
|
row = None
|
||||||
|
|
||||||
|
# Step 1: exact full-digit match (strips country code first)
|
||||||
|
if full:
|
||||||
|
query_full = _contact_cte + """
|
||||||
|
WHERE regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g') LIKE %s
|
||||||
|
OR regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g') LIKE %s
|
||||||
|
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
# Match ending with full digits (covers both with and without country code stored)
|
||||||
|
pattern = f"%{full}"
|
||||||
|
row = execute_query_single(query_full, (pattern, pattern))
|
||||||
|
if row:
|
||||||
|
logger.debug("📞 Phone lookup: full-digit match for %s → contact %s", number, row["id"])
|
||||||
|
|
||||||
|
# Step 2: 8-digit suffix fallback
|
||||||
|
if not row and suffix:
|
||||||
|
query_suffix = _contact_cte + """
|
||||||
|
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
|
||||||
|
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
|
||||||
|
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
row = execute_query_single(query_suffix, (suffix, suffix))
|
||||||
|
if row:
|
||||||
|
logger.debug("📞 Phone lookup: suffix-8 fallback for %s → contact %s", number, row["id"])
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"name": f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip(),
|
"name": f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip(),
|
||||||
|
"company_id": row.get("company_id"),
|
||||||
"company": row.get("company"),
|
"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
|
@staticmethod
|
||||||
def upsert_call(
|
def upsert_call(
|
||||||
*,
|
*,
|
||||||
@ -103,7 +161,13 @@ class TelefoniService:
|
|||||||
"""
|
"""
|
||||||
UPDATE telefoni_opkald
|
UPDATE telefoni_opkald
|
||||||
SET ended_at = NOW(),
|
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
|
WHERE callid = %s
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
|
|||||||
@ -46,6 +46,21 @@ def phone_suffix_8(number: Optional[str]) -> Optional[str]:
|
|||||||
return d[-8:]
|
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:
|
def is_outbound_call(caller: Optional[str], local_extension: Optional[str]) -> bool:
|
||||||
caller_d = digits_only(caller)
|
caller_d = digits_only(caller)
|
||||||
local_d = digits_only(local_extension)
|
local_d = digits_only(local_extension)
|
||||||
|
|||||||
@ -4,6 +4,8 @@ REST API endpoints for managing remote support sessions
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
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,
|
s.created_by_user_id, s.created_at, s.updated_at,
|
||||||
c.first_name || ' ' || c.last_name as contact_name,
|
c.first_name || ' ' || c.last_name as contact_name,
|
||||||
cust.name as customer_name,
|
cust.name as customer_name,
|
||||||
sag.title as sag_title,
|
sag.titel as sag_title,
|
||||||
u.full_name as created_by_user_name,
|
u.full_name as created_by_user_name,
|
||||||
s.device_info, s.metadata
|
s.device_info, s.metadata
|
||||||
FROM anydesk_sessions s
|
FROM anydesk_sessions s
|
||||||
LEFT JOIN contacts c ON s.contact_id = c.id
|
LEFT JOIN contacts c ON s.contact_id = c.id
|
||||||
LEFT JOIN customers cust ON s.customer_id = cust.id
|
LEFT JOIN customers cust ON s.customer_id = cust.id
|
||||||
LEFT JOIN sag_sager sag ON s.sag_id = sag.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
|
WHERE s.id = %s
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -149,6 +151,197 @@ async def end_remote_session(session_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
|
||||||
async def get_session_history(
|
async def get_session_history(
|
||||||
contact_id: Optional[int] = None,
|
contact_id: Optional[int] = None,
|
||||||
@ -170,13 +363,6 @@ async def get_session_history(
|
|||||||
- **offset**: Pagination offset
|
- **offset**: Pagination offset
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
if limit > 100:
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|
||||||
@ -369,11 +555,268 @@ async def anydesk_health_check():
|
|||||||
|
|
||||||
Returns configuration status, API connectivity, and last sync time
|
Returns configuration status, API connectivity, and last sync time
|
||||||
"""
|
"""
|
||||||
|
creds = anydesk_service._get_credentials()
|
||||||
return JSONResponse(content={
|
return JSONResponse(content={
|
||||||
"service": "AnyDesk Remote Support",
|
"service": "AnyDesk Remote Support",
|
||||||
"status": "operational",
|
"status": "operational",
|
||||||
"configured": bool(anydesk_service.api_token and anydesk_service.license_id),
|
"configured": bool(creds["api_token"] and creds["license_id"]),
|
||||||
"dry_run_mode": anydesk_service.dry_run,
|
"dry_run_mode": creds["dry_run"],
|
||||||
"read_only_mode": anydesk_service.read_only,
|
"read_only_mode": creds["read_only"],
|
||||||
"auto_start_enabled": anydesk_service.auto_start
|
"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 logging
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import httpx
|
import httpx
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
@ -23,79 +28,117 @@ class AnyDeskService:
|
|||||||
Respects safety switches: READ_ONLY and DRY_RUN
|
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):
|
def __init__(self):
|
||||||
self.api_token = settings.ANYDESK_API_TOKEN
|
# Credentials loaded lazily from DB at call-time (via _get_credentials)
|
||||||
self.license_id = settings.ANYDESK_LICENSE_ID
|
# Fall back to .env values if DB has nothing
|
||||||
self.read_only = settings.ANYDESK_READ_ONLY
|
self._timeout = settings.ANYDESK_TIMEOUT_SECONDS
|
||||||
self.dry_run = settings.ANYDESK_DRY_RUN
|
|
||||||
self.timeout = settings.ANYDESK_TIMEOUT_SECONDS
|
|
||||||
self.auto_start = settings.ANYDESK_AUTO_START_SESSION
|
self.auto_start = settings.ANYDESK_AUTO_START_SESSION
|
||||||
|
|
||||||
if not self.api_token or not self.license_id:
|
def _get_credentials(self) -> Dict[str, Any]:
|
||||||
logger.warning("⚠️ AnyDesk credentials not configured - service disabled")
|
"""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:
|
def _check_enabled(self) -> bool:
|
||||||
"""Check if AnyDesk is properly configured"""
|
"""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)")
|
logger.warning("AnyDesk service not configured (missing credentials)")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _api_call(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
|
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():
|
if not self._check_enabled():
|
||||||
return {"error": "AnyDesk not configured"}
|
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}"
|
log_msg = f"🔗 AnyDesk API: {method} {endpoint}"
|
||||||
if data:
|
if data:
|
||||||
log_msg += f" | Data: {json.dumps(data, indent=2)}"
|
log_msg += f" | Data: {json.dumps(data, indent=2)}"
|
||||||
logger.info(log_msg)
|
logger.info(log_msg)
|
||||||
|
|
||||||
# DRY RUN: Don't actually call API
|
# 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)")
|
logger.warning("⚠️ DRY_RUN=true: Simulating API response (no actual call)")
|
||||||
return self._simulate_response(method, endpoint, data)
|
return self._simulate_response(method, endpoint, data)
|
||||||
|
|
||||||
# READ ONLY: Allow gets but not mutations
|
# 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")
|
logger.warning(f"🔒 READ_ONLY=true: Blocking {method} request")
|
||||||
return {"error": "Read-only mode: mutations disabled"}
|
return {"error": "Read-only mode: mutations disabled"}
|
||||||
|
|
||||||
|
body_str = json.dumps(data) if data else ""
|
||||||
|
auth_header = self._generate_auth_header(endpoint, body_str, method)
|
||||||
|
headers = {
|
||||||
|
"Authorization": auth_header,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
url = f"{self.BASE_URL}{endpoint}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
headers = {
|
async with aiohttp.ClientSession() as session:
|
||||||
"Authorization": f"Bearer {self.api_token}",
|
kwargs = {"headers": headers, "timeout": aiohttp.ClientTimeout(total=self.timeout)}
|
||||||
"Content-Type": "application/json"
|
if data:
|
||||||
}
|
kwargs["json"] = data
|
||||||
|
async with getattr(session, method.lower())(url, **kwargs) as response:
|
||||||
url = f"{self.BASE_URL}{endpoint}"
|
response_text = await response.text()
|
||||||
|
logger.info(f"📡 AnyDesk API {response.status}: {response_text[:200]}")
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
if response.status == 200:
|
||||||
if method == "GET":
|
try:
|
||||||
response = await client.get(url, headers=headers)
|
return await response.json(content_type=None)
|
||||||
elif method == "POST":
|
except Exception:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
return {"raw": response_text}
|
||||||
elif method == "PUT":
|
elif response.status == 401:
|
||||||
response = await client.put(url, headers=headers, json=data)
|
logger.error(f"❌ AnyDesk auth failed — check license_id + api_token")
|
||||||
elif method == "DELETE":
|
return {"error": f"Unauthorized (401): {response_text[:200]}"}
|
||||||
response = await client.delete(url, headers=headers)
|
else:
|
||||||
else:
|
logger.error(f"❌ AnyDesk API error {response.status}: {response_text[:300]}")
|
||||||
return {"error": f"Unsupported method: {method}"}
|
return {"error": f"HTTP {response.status}: {response_text[:300]}"}
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"❌ AnyDesk API error: {str(e)}")
|
logger.error(f"❌ AnyDesk API error: {str(e)}")
|
||||||
@ -156,11 +199,13 @@ class AnyDeskService:
|
|||||||
Returns:
|
Returns:
|
||||||
Session data with session_id, link, access_code, etc.
|
Session data with session_id, link, access_code, etc.
|
||||||
"""
|
"""
|
||||||
|
creds = self._get_credentials()
|
||||||
|
|
||||||
# Prepare session data
|
# Prepare session data
|
||||||
session_data = {
|
session_data = {
|
||||||
"name": f"BMC Support - Customer {customer_id}",
|
"name": f"BMC Support - Customer {customer_id}",
|
||||||
"description": description or f"Support session for 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
|
"auto_accept": True # Auto-accept connection requests
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,7 +234,7 @@ class AnyDeskService:
|
|||||||
device_info = {
|
device_info = {
|
||||||
"created_via": "api",
|
"created_via": "api",
|
||||||
"auto_start": self.auto_start,
|
"auto_start": self.auto_start,
|
||||||
"dry_run_mode": self.dry_run
|
"dry_run_mode": creds["dry_run"]
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
@ -385,7 +430,7 @@ class AnyDeskService:
|
|||||||
s.created_by_user_id, s.created_at, s.updated_at,
|
s.created_by_user_id, s.created_at, s.updated_at,
|
||||||
c.first_name || ' ' || c.last_name as contact_name,
|
c.first_name || ' ' || c.last_name as contact_name,
|
||||||
cust.name as customer_name,
|
cust.name as customer_name,
|
||||||
sag.title as sag_title,
|
sag.titel as sag_title,
|
||||||
u.full_name as created_by_user_name,
|
u.full_name as created_by_user_name,
|
||||||
s.device_info, s.metadata
|
s.device_info, s.metadata
|
||||||
FROM anydesk_sessions s
|
FROM anydesk_sessions s
|
||||||
@ -422,3 +467,113 @@ class AnyDeskService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching session history: {str(e)}")
|
logger.error(f"Error fetching session history: {str(e)}")
|
||||||
return {"error": str(e), "sessions": []}
|
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,
|
||||||
|
}
|
||||||
|
|||||||
263
app/services/brother_label_print_service.py
Normal file
263
app/services/brother_label_print_service.py
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"""Brother QL direct print service for case hardware labels."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Compatibility shim: brother_ql may still reference Image.ANTIALIAS,
|
||||||
|
# which was removed in newer Pillow releases.
|
||||||
|
if not hasattr(Image, "ANTIALIAS") and hasattr(Image, "Resampling"):
|
||||||
|
Image.ANTIALIAS = Image.Resampling.LANCZOS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.labels import ALL_LABELS
|
||||||
|
except Exception: # pragma: no cover - handled at runtime
|
||||||
|
send = None
|
||||||
|
convert = None
|
||||||
|
BrotherQLRaster = None
|
||||||
|
ALL_LABELS = None
|
||||||
|
|
||||||
|
|
||||||
|
_CODE39_PATTERNS = {
|
||||||
|
"0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn",
|
||||||
|
"4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw",
|
||||||
|
"8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw",
|
||||||
|
"C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn",
|
||||||
|
"G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn",
|
||||||
|
"K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww",
|
||||||
|
"O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn",
|
||||||
|
"S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw",
|
||||||
|
"W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn",
|
||||||
|
"-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn",
|
||||||
|
"/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LabelJob:
|
||||||
|
name: str
|
||||||
|
meta_line: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class BrotherLabelPrintService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
label_size: str,
|
||||||
|
) -> None:
|
||||||
|
self.model = (model or "QL-710W").strip()
|
||||||
|
self.host = (host or "").strip()
|
||||||
|
self.port = int(port or 9100)
|
||||||
|
self.label_size = self._normalize_label_size((label_size or "62").strip())
|
||||||
|
self.label_spec = self._resolve_label_spec(self.label_size)
|
||||||
|
self.printable_width = self._resolve_printable_width(self.label_size)
|
||||||
|
self.printable_height = self._resolve_printable_height(self.label_size)
|
||||||
|
self.is_die_cut = bool(self.label_spec and getattr(self.label_spec, "form_factor", None) and "DIE_CUT" in str(getattr(self.label_spec, "form_factor", "")))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def printer_identifier(self) -> str:
|
||||||
|
return f"tcp://{self.host}:{self.port}"
|
||||||
|
|
||||||
|
def print_jobs(self, jobs: Iterable[LabelJob]) -> int:
|
||||||
|
if not self.host:
|
||||||
|
raise ValueError("Printer host is missing")
|
||||||
|
if not send or not convert or not BrotherQLRaster:
|
||||||
|
raise RuntimeError("brother_ql library is not installed in this environment")
|
||||||
|
|
||||||
|
send_func = send
|
||||||
|
convert_func = convert
|
||||||
|
raster_cls = BrotherQLRaster
|
||||||
|
|
||||||
|
rendered_images = [self._build_label_image(job) for job in jobs]
|
||||||
|
if not rendered_images:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
qlr = raster_cls(self.model)
|
||||||
|
instructions = convert_func(
|
||||||
|
qlr=qlr,
|
||||||
|
images=rendered_images,
|
||||||
|
label=self.label_size,
|
||||||
|
rotate='auto' if self.is_die_cut else 0,
|
||||||
|
cut=True,
|
||||||
|
dither=False,
|
||||||
|
compress=False,
|
||||||
|
red=False,
|
||||||
|
dpi_600=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_to_printer(instructions, send_func)
|
||||||
|
return len(rendered_images)
|
||||||
|
|
||||||
|
def _send_to_printer(self, instructions: List[bytes], send_func) -> None:
|
||||||
|
target = self.printer_identifier
|
||||||
|
# brother_ql helper changed call signature across versions.
|
||||||
|
try:
|
||||||
|
send_func(instructions, target, "network", blocking=True)
|
||||||
|
return
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_func(instructions=instructions, printer_identifier=target, backend_identifier="network", blocking=True)
|
||||||
|
return
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Final fallback to raw socket stream for network printers.
|
||||||
|
payload = b"".join(instructions)
|
||||||
|
with socket.create_connection((self.host, self.port), timeout=10) as conn:
|
||||||
|
conn.sendall(payload)
|
||||||
|
|
||||||
|
def _build_label_image(self, job: LabelJob) -> Image.Image:
|
||||||
|
width = self.printable_width
|
||||||
|
height = self.printable_height if self.printable_height > 0 else 220
|
||||||
|
image = Image.new("RGB", (width, height), "white")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
font_title = ImageFont.load_default()
|
||||||
|
font_meta = ImageFont.load_default()
|
||||||
|
font_token = ImageFont.load_default()
|
||||||
|
|
||||||
|
title = (job.name or "Ukendt enhed")[:52]
|
||||||
|
meta = (job.meta_line or "-")[:88]
|
||||||
|
token = (job.token or "")[:64]
|
||||||
|
|
||||||
|
left = 12
|
||||||
|
top = 8
|
||||||
|
right = max(left + 1, width - 12)
|
||||||
|
|
||||||
|
# Compact layout for die-cut labels to fit exact printable area.
|
||||||
|
if self.is_die_cut:
|
||||||
|
title_y = top
|
||||||
|
meta_y = title_y + 18
|
||||||
|
barcode_y = meta_y + 16
|
||||||
|
token_y = min(height - 14, barcode_y + max(26, int(height * 0.28)) + 4)
|
||||||
|
bar_height = max(24, min(int(height * 0.28), height - barcode_y - 22))
|
||||||
|
else:
|
||||||
|
title_y = 12
|
||||||
|
meta_y = 34
|
||||||
|
barcode_y = 64
|
||||||
|
token_y = min(height - 16, 170)
|
||||||
|
bar_height = max(48, min(92, height - barcode_y - 26))
|
||||||
|
|
||||||
|
draw.text((left, title_y), title, fill="black", font=font_title)
|
||||||
|
draw.text((left, meta_y), meta, fill="black", font=font_meta)
|
||||||
|
self._draw_code39(draw, token, x=left, y=barcode_y, max_width=max(60, right - left), bar_height=bar_height)
|
||||||
|
draw.text((left, token_y), token, fill="black", font=font_token)
|
||||||
|
return image
|
||||||
|
|
||||||
|
def _normalize_label_size(self, label_size: str) -> str:
|
||||||
|
wanted = str(label_size or "").strip()
|
||||||
|
if wanted == "29":
|
||||||
|
# Legacy compatibility: old config often used "29" while hardware stock is 62x29 die-cut.
|
||||||
|
logger.warning("⚠️ Label size '29' mapped to '62x29' for Brother QL hardware labels")
|
||||||
|
return "62x29"
|
||||||
|
return wanted or "62"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_label_spec(label_size: str):
|
||||||
|
if not ALL_LABELS:
|
||||||
|
return None
|
||||||
|
wanted = str(label_size or "").strip()
|
||||||
|
for lbl in ALL_LABELS:
|
||||||
|
if getattr(lbl, "identifier", "") == wanted:
|
||||||
|
return lbl
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_printable_width(label_size: str) -> int:
|
||||||
|
default_width = 696 # 62mm endless printable width
|
||||||
|
if not ALL_LABELS:
|
||||||
|
return default_width
|
||||||
|
try:
|
||||||
|
wanted = str(label_size or "").strip()
|
||||||
|
for lbl in ALL_LABELS:
|
||||||
|
if getattr(lbl, "identifier", "") == wanted:
|
||||||
|
dots = getattr(lbl, "dots_printable", None)
|
||||||
|
if isinstance(dots, tuple) and len(dots) > 0 and int(dots[0]) > 0:
|
||||||
|
return int(dots[0])
|
||||||
|
except Exception:
|
||||||
|
return default_width
|
||||||
|
return default_width
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_printable_height(label_size: str) -> int:
|
||||||
|
if not ALL_LABELS:
|
||||||
|
return 220
|
||||||
|
try:
|
||||||
|
wanted = str(label_size or "").strip()
|
||||||
|
for lbl in ALL_LABELS:
|
||||||
|
if getattr(lbl, "identifier", "") == wanted:
|
||||||
|
dots = getattr(lbl, "dots_printable", None)
|
||||||
|
if isinstance(dots, tuple) and len(dots) > 1 and int(dots[1]) > 0:
|
||||||
|
return int(dots[1])
|
||||||
|
return 220
|
||||||
|
except Exception:
|
||||||
|
return 220
|
||||||
|
return 220
|
||||||
|
|
||||||
|
def _draw_code39(
|
||||||
|
self,
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
value: str,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
max_width: int,
|
||||||
|
bar_height: int,
|
||||||
|
) -> None:
|
||||||
|
safe = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*")
|
||||||
|
if not safe:
|
||||||
|
safe = "EMPTY"
|
||||||
|
seq = f"*{safe}*"
|
||||||
|
|
||||||
|
# Prefer physically narrower bars first; scanners struggle when Code39
|
||||||
|
# modules become too wide on small die-cut labels.
|
||||||
|
variants = [
|
||||||
|
(1, 2, 0),
|
||||||
|
(1, 3, 1),
|
||||||
|
(2, 5, 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
narrow, wide, gap = variants[0]
|
||||||
|
for candidate in variants:
|
||||||
|
c_narrow, c_wide, c_gap = candidate
|
||||||
|
width = self._code39_width(seq, c_narrow, c_wide, c_gap)
|
||||||
|
if width <= max_width:
|
||||||
|
narrow, wide, gap = c_narrow, c_wide, c_gap
|
||||||
|
break
|
||||||
|
|
||||||
|
cursor = x
|
||||||
|
for ch in seq:
|
||||||
|
pattern = _CODE39_PATTERNS[ch]
|
||||||
|
for idx, code in enumerate(pattern):
|
||||||
|
stroke = wide if code == "w" else narrow
|
||||||
|
if idx % 2 == 0:
|
||||||
|
draw.rectangle([cursor, y, cursor + stroke - 1, y + bar_height], fill="black")
|
||||||
|
cursor += stroke
|
||||||
|
if idx < len(pattern) - 1:
|
||||||
|
cursor += gap
|
||||||
|
cursor += gap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _code39_width(sequence: str, narrow: int, wide: int, gap: int) -> int:
|
||||||
|
total = 0
|
||||||
|
for ch in sequence:
|
||||||
|
pattern = _CODE39_PATTERNS[ch]
|
||||||
|
for idx, code in enumerate(pattern):
|
||||||
|
total += wide if code == "w" else narrow
|
||||||
|
if idx < len(pattern) - 1:
|
||||||
|
total += gap
|
||||||
|
total += gap
|
||||||
|
return total
|
||||||
@ -1,20 +1,59 @@
|
|||||||
"""
|
"""
|
||||||
CVR.dk API service for looking up Danish company information
|
CVR service for looking up Danish company information.
|
||||||
Free public API - no authentication required
|
|
||||||
Adapted from OmniSync for BMC Hub
|
Primary provider: FirmaAPI (authenticated).
|
||||||
|
Legacy fallback: cvrapi.dk when no FirmaAPI key is configured.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CVRService:
|
class CVRService:
|
||||||
"""Service for CVR.dk API lookups"""
|
"""Service for CVR lookups using FirmaAPI (or legacy fallback)."""
|
||||||
|
|
||||||
BASE_URL = "https://cvrapi.dk/api"
|
LEGACY_BASE_URL = "https://cvrapi.dk/api"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def firmaapi_base_url(self) -> str:
|
||||||
|
return settings.FIRMAAPI_BASE_URL.rstrip("/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def firmaapi_timeout(self) -> aiohttp.ClientTimeout:
|
||||||
|
return aiohttp.ClientTimeout(total=settings.FIRMAAPI_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_firmaapi_key(self) -> bool:
|
||||||
|
return bool((settings.FIRMAAPI_API_KEY or "").strip())
|
||||||
|
|
||||||
|
def _firmaapi_headers(self) -> Dict[str, str]:
|
||||||
|
api_key = (settings.FIRMAAPI_API_KEY or "").strip()
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_payload(payload: Dict) -> Dict:
|
||||||
|
return {
|
||||||
|
"cvr": payload.get("cvr") or payload.get("vat"),
|
||||||
|
"name": payload.get("name"),
|
||||||
|
"address": payload.get("address"),
|
||||||
|
"city": payload.get("city"),
|
||||||
|
"zipcode": payload.get("zipcode"),
|
||||||
|
"postal_code": payload.get("zipcode") or payload.get("postal_code"),
|
||||||
|
"country": payload.get("country") or "DK",
|
||||||
|
"phone": payload.get("phone"),
|
||||||
|
"email": payload.get("email"),
|
||||||
|
"website": payload.get("website"),
|
||||||
|
"status": payload.get("status"),
|
||||||
|
"source": "firmaapi" if payload.get("meta", {}).get("source") == "FirmaAPI" else payload.get("source", "firmaapi"),
|
||||||
|
}
|
||||||
|
|
||||||
async def lookup_by_name(self, company_name: str) -> Optional[Dict]:
|
async def lookup_by_name(self, company_name: str) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
@ -33,42 +72,43 @@ class CVRService:
|
|||||||
clean_name = company_name.strip()
|
clean_name = company_name.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
params = {
|
if self.has_firmaapi_key:
|
||||||
'search': clean_name,
|
async with aiohttp.ClientSession() as session:
|
||||||
'country': 'dk'
|
async with session.get(
|
||||||
}
|
f"{self.firmaapi_base_url}/company/search",
|
||||||
|
params={"q": clean_name, "limit": 1},
|
||||||
|
headers=self._firmaapi_headers(),
|
||||||
|
timeout=self.firmaapi_timeout,
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
results = data.get("results") or []
|
||||||
|
if results:
|
||||||
|
match = results[0]
|
||||||
|
logger.info("✅ Found CVR %s for '%s' via FirmaAPI", match.get("cvr"), company_name)
|
||||||
|
return self._normalize_payload(match)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if response.status == 404:
|
||||||
|
return None
|
||||||
|
|
||||||
|
detail = await response.text()
|
||||||
|
logger.error("❌ FirmaAPI name lookup error %s for '%s': %s", response.status, company_name, detail[:240])
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Legacy fallback without API key
|
||||||
|
params = {"search": clean_name, "country": "dk"}
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{self.BASE_URL}",
|
f"{self.LEGACY_BASE_URL}",
|
||||||
params=params,
|
params=params,
|
||||||
timeout=aiohttp.ClientTimeout(total=10)
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
if data and "vat" in data:
|
||||||
if data and 'vat' in data:
|
return self._normalize_payload(data)
|
||||||
logger.info(f"✅ Found CVR {data['vat']} for '{company_name}'")
|
return None
|
||||||
return {
|
|
||||||
'cvr': data.get('vat'),
|
|
||||||
'name': data.get('name'),
|
|
||||||
'address': data.get('address'),
|
|
||||||
'city': data.get('city'),
|
|
||||||
'zipcode': data.get('zipcode'),
|
|
||||||
'country': data.get('country'),
|
|
||||||
'phone': data.get('phone'),
|
|
||||||
'email': data.get('email'),
|
|
||||||
'vat': data.get('vat'),
|
|
||||||
'status': data.get('status')
|
|
||||||
}
|
|
||||||
|
|
||||||
elif response.status == 404:
|
|
||||||
logger.warning(f"⚠️ No CVR found for '{company_name}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.error(f"❌ CVR API error {response.status} for '{company_name}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"⏱️ CVR API timeout for '{company_name}'")
|
logger.error(f"⏱️ CVR API timeout for '{company_name}'")
|
||||||
@ -99,31 +139,37 @@ class CVRService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if self.has_firmaapi_key:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.firmaapi_base_url}/company/{cvr_clean}",
|
||||||
|
headers=self._firmaapi_headers(),
|
||||||
|
timeout=self.firmaapi_timeout,
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
logger.info("✅ Validated CVR %s via FirmaAPI", cvr_clean)
|
||||||
|
return self._normalize_payload(data)
|
||||||
|
|
||||||
|
if response.status in (400, 404):
|
||||||
|
return None
|
||||||
|
|
||||||
|
detail = await response.text()
|
||||||
|
logger.error("❌ FirmaAPI CVR lookup error %s for %s: %s", response.status, cvr_clean, detail[:240])
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Legacy fallback without API key
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{self.BASE_URL}",
|
f"{self.LEGACY_BASE_URL}",
|
||||||
params={'vat': cvr_clean, 'country': 'dk'},
|
params={"vat": cvr_clean, "country": "dk"},
|
||||||
timeout=aiohttp.ClientTimeout(total=10)
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
if data and "vat" in data:
|
||||||
if data and 'vat' in data:
|
logger.info("✅ Validated CVR %s via legacy CVR API", cvr_clean)
|
||||||
logger.info(f"✅ Validated CVR {cvr_clean}")
|
return self._normalize_payload(data)
|
||||||
return {
|
|
||||||
'cvr': data.get('vat'),
|
|
||||||
'name': data.get('name'),
|
|
||||||
'address': data.get('address'),
|
|
||||||
'city': data.get('city'),
|
|
||||||
'zipcode': data.get('zipcode'),
|
|
||||||
'postal_code': data.get('zipcode'), # Alias for consistency
|
|
||||||
'country': data.get('country'),
|
|
||||||
'phone': data.get('phone'),
|
|
||||||
'email': data.get('email'),
|
|
||||||
'vat': data.get('vat'),
|
|
||||||
'status': data.get('status')
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -731,12 +731,9 @@ class EmailService:
|
|||||||
Priority:
|
Priority:
|
||||||
1) First References token (root message id)
|
1) First References token (root message id)
|
||||||
2) In-Reply-To
|
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"))
|
reference_ids = self._extract_reference_ids(email_data.get("email_references"))
|
||||||
if reference_ids:
|
if reference_ids:
|
||||||
return reference_ids[0]
|
return reference_ids[0]
|
||||||
@ -745,6 +742,10 @@ class EmailService:
|
|||||||
if in_reply_to:
|
if in_reply_to:
|
||||||
return 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"))
|
return self._normalize_message_id_value(email_data.get("message_id"))
|
||||||
|
|
||||||
def _parse_email_date(self, date_str: str) -> datetime:
|
def _parse_email_date(self, date_str: str) -> datetime:
|
||||||
@ -766,11 +767,99 @@ class EmailService:
|
|||||||
result = execute_query(query, (message_id,))
|
result = execute_query(query, (message_id,))
|
||||||
return len(result) > 0
|
return len(result) > 0
|
||||||
|
|
||||||
|
def _adopt_parent_thread_key(self, email_data: Dict, derived_thread_key: Optional[str]) -> Optional[str]:
|
||||||
|
"""Look up parent emails by References/In-Reply-To and adopt their thread_key
|
||||||
|
so outgoing+incoming emails share the same canonical group key."""
|
||||||
|
|
||||||
|
# Strategy 1: If the email has an explicit provider thread key (e.g. Graph
|
||||||
|
# conversationId), check if ANY existing email in the DB already uses it as
|
||||||
|
# its thread_key. ConversationId is the most reliable stable identifier
|
||||||
|
# across all emails in an Exchange conversation.
|
||||||
|
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
|
||||||
|
if explicit_thread_key:
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT thread_key
|
||||||
|
FROM email_messages
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(REGEXP_REPLACE(COALESCE(thread_key, ''), '[<>\\s]', '', 'g')) = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(explicit_thread_key,),
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
logger.info(
|
||||||
|
"🧵 Adopted conversationId thread_key '%s' for incoming email (derived was '%s')",
|
||||||
|
explicit_thread_key,
|
||||||
|
derived_thread_key,
|
||||||
|
)
|
||||||
|
return explicit_thread_key
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("⚠️ Failed conversationId thread_key lookup: %s", e)
|
||||||
|
|
||||||
|
# Strategy 2: Look up parent emails by message_id matching our
|
||||||
|
# References/In-Reply-To headers.
|
||||||
|
parent_ids: List[str] = []
|
||||||
|
ref_ids = self._extract_reference_ids(email_data.get("email_references"))
|
||||||
|
parent_ids.extend(ref_ids)
|
||||||
|
in_reply = self._normalize_message_id_value(email_data.get("in_reply_to"))
|
||||||
|
if in_reply and in_reply not in parent_ids:
|
||||||
|
parent_ids.append(in_reply)
|
||||||
|
|
||||||
|
if not parent_ids:
|
||||||
|
# Strategy 3: No thread headers at all — try conversationId as thread_key
|
||||||
|
# even if no existing email has it yet (new conversation from Graph).
|
||||||
|
if explicit_thread_key:
|
||||||
|
return explicit_thread_key
|
||||||
|
return derived_thread_key
|
||||||
|
|
||||||
|
# Query parent emails that already have a thread_key stored
|
||||||
|
placeholders = ",".join(["%s"] * len(parent_ids))
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT thread_key
|
||||||
|
FROM email_messages
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND thread_key IS NOT NULL
|
||||||
|
AND TRIM(thread_key) != ''
|
||||||
|
AND LOWER(REGEXP_REPLACE(COALESCE(message_id, ''), '[<>\\s]', '', 'g')) IN ({placeholders})
|
||||||
|
ORDER BY received_date ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
tuple(parent_ids),
|
||||||
|
)
|
||||||
|
if rows and rows[0].get("thread_key"):
|
||||||
|
adopted = self._normalize_message_id_value(rows[0]["thread_key"])
|
||||||
|
if adopted:
|
||||||
|
logger.info(
|
||||||
|
"🧵 Adopted parent thread_key '%s' for incoming email (derived was '%s')",
|
||||||
|
adopted,
|
||||||
|
derived_thread_key,
|
||||||
|
)
|
||||||
|
return adopted
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("⚠️ Failed to adopt parent thread_key: %s", e)
|
||||||
|
|
||||||
|
# Fallback: prefer the explicit conversationId over derived References[0]
|
||||||
|
# since the References message-id often doesn't match any stored message_id
|
||||||
|
if explicit_thread_key:
|
||||||
|
return explicit_thread_key
|
||||||
|
|
||||||
|
return derived_thread_key
|
||||||
|
|
||||||
async def save_email(self, email_data: Dict) -> Optional[int]:
|
async def save_email(self, email_data: Dict) -> Optional[int]:
|
||||||
"""Save email to database"""
|
"""Save email to database"""
|
||||||
try:
|
try:
|
||||||
thread_key = self._derive_thread_key(email_data)
|
thread_key = self._derive_thread_key(email_data)
|
||||||
|
|
||||||
|
# When this email is a reply, look up the parent email(s) by
|
||||||
|
# message_id matching our References/In-Reply-To. If the parent
|
||||||
|
# already has a thread_key stored, adopt it so both emails share the
|
||||||
|
# same canonical key and are grouped in the same visual thread.
|
||||||
|
thread_key = self._adopt_parent_thread_key(email_data, thread_key)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO email_messages
|
INSERT INTO email_messages
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import re
|
|||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import shutil
|
import shutil
|
||||||
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_insert, execute_update, table_has_column
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.email_activity_logger import email_activity_logger
|
from app.services.email_activity_logger import email_activity_logger
|
||||||
|
|
||||||
@ -38,6 +40,8 @@ class EmailWorkflowService:
|
|||||||
'recording'
|
'recording'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SCAN_TOKEN_PATTERN = re.compile(r'\bBMCSCAN-[A-Z0-9-]{10,100}\b', re.IGNORECASE)
|
||||||
|
|
||||||
async def execute_workflows(self, email_data: Dict) -> Dict:
|
async def execute_workflows(self, email_data: Dict) -> Dict:
|
||||||
"""
|
"""
|
||||||
Execute all matching workflows for an email
|
Execute all matching workflows for an email
|
||||||
@ -91,11 +95,16 @@ class EmailWorkflowService:
|
|||||||
logger.info("✅ Bankruptcy system workflow executed successfully")
|
logger.info("✅ Bankruptcy system workflow executed successfully")
|
||||||
|
|
||||||
# Special System Workflow: Helpdesk SAG routing
|
# Special System Workflow: Helpdesk SAG routing
|
||||||
# - If SAG/tråd-hint findes => forsøg altid routing til eksisterende sag
|
# - If SAG/tråd-hint findes => forsøg routing til eksisterende sag
|
||||||
|
# - Newsletters/spam skip routing ENTIRELY (even with thread hints)
|
||||||
# - Uden hints: brug klassifikationsgating som før
|
# - Uden hints: brug klassifikationsgating som før
|
||||||
|
HARD_SKIP = {'newsletter', 'spam'}
|
||||||
should_try_helpdesk = (
|
should_try_helpdesk = (
|
||||||
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
|
classification not in HARD_SKIP
|
||||||
or has_hint
|
and (
|
||||||
|
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
|
||||||
|
or has_hint
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_try_helpdesk:
|
if should_try_helpdesk:
|
||||||
@ -223,12 +232,16 @@ class EmailWorkflowService:
|
|||||||
return domain or None
|
return domain or None
|
||||||
|
|
||||||
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
|
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
|
||||||
"""Return True when email has explicit routing hints (SAG or thread headers/key)."""
|
"""Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers).
|
||||||
if self._extract_sag_id(email_data):
|
|
||||||
|
NOTE: A bare thread_key (Graph conversationId) is NOT a routing hint
|
||||||
|
because every Graph email has one, including newsletters and spam.
|
||||||
|
Only actual reply indicators (In-Reply-To, References), explicit
|
||||||
|
SAG tags, or BMCid markers count as hints."""
|
||||||
|
if self._extract_bmc_id(email_data):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
explicit_thread_key = self._normalize_message_id(email_data.get('thread_key'))
|
if self._extract_sag_id(email_data):
|
||||||
if explicit_thread_key:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self._normalize_message_id(email_data.get('in_reply_to')):
|
if self._normalize_message_id(email_data.get('in_reply_to')):
|
||||||
@ -239,7 +252,33 @@ class EmailWorkflowService:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract structured BMCid from email body/subject.
|
||||||
|
|
||||||
|
Returns dict with 'sag_id' (int) and 'thread_suffix' (str, e.g. '472193')
|
||||||
|
or None if no BMCid is found.
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
email_data.get('body_html') or '',
|
||||||
|
email_data.get('body_text') or '',
|
||||||
|
email_data.get('subject') or '',
|
||||||
|
]
|
||||||
|
pattern = r'\bBMCid\s*:\s*s(\d+)t(\d+)\b'
|
||||||
|
for value in candidates:
|
||||||
|
match = re.search(pattern, value, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return {
|
||||||
|
'sag_id': int(match.group(1)),
|
||||||
|
'thread_suffix': match.group(2),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
|
def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
|
||||||
|
# First try structured BMCid (most reliable)
|
||||||
|
bmc_id = self._extract_bmc_id(email_data)
|
||||||
|
if bmc_id:
|
||||||
|
return bmc_id['sag_id']
|
||||||
|
|
||||||
candidates = [
|
candidates = [
|
||||||
email_data.get('subject') or '',
|
email_data.get('subject') or '',
|
||||||
email_data.get('in_reply_to') or '',
|
email_data.get('in_reply_to') or '',
|
||||||
@ -249,14 +288,15 @@ class EmailWorkflowService:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Accept both strict and human variants used in real subjects, e.g.:
|
# Accept both strict and human variants used in real subjects, e.g.:
|
||||||
|
# - [SAG-53] (hidden/subject prefix)
|
||||||
# - SAG-53
|
# - SAG-53
|
||||||
# - SAG #53
|
# - SAG #53
|
||||||
# - Sag 53
|
# - Sag 53
|
||||||
sag_patterns = [
|
sag_patterns = [
|
||||||
|
r'\[SAG-(\d+)\]',
|
||||||
r'\bSAG-(\d+)\b',
|
r'\bSAG-(\d+)\b',
|
||||||
r'\bSAG\s*#\s*(\d+)\b',
|
r'\bSAG\s*#\s*(\d+)\b',
|
||||||
r'\bSAG\s+(\d+)\b',
|
r'\bSAG\s+(\d+)\b',
|
||||||
r'\bBMCid\s*:\s*s(\d+)t\d+\b',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for value in candidates:
|
for value in candidates:
|
||||||
@ -299,10 +339,7 @@ class EmailWorkflowService:
|
|||||||
return list(dict.fromkeys(tokens))
|
return list(dict.fromkeys(tokens))
|
||||||
|
|
||||||
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
|
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
|
||||||
"""Derive stable conversation key: root References -> In-Reply-To -> Message-ID."""
|
"""Derive stable conversation key: root References -> In-Reply-To -> explicit -> Message-ID."""
|
||||||
explicit = self._normalize_message_id(email_data.get('thread_key'))
|
|
||||||
if explicit:
|
|
||||||
return explicit
|
|
||||||
|
|
||||||
ref_ids = self._extract_reference_message_ids(email_data.get('email_references'))
|
ref_ids = self._extract_reference_message_ids(email_data.get('email_references'))
|
||||||
if ref_ids:
|
if ref_ids:
|
||||||
@ -312,6 +349,10 @@ class EmailWorkflowService:
|
|||||||
if in_reply_to:
|
if in_reply_to:
|
||||||
return 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'))
|
return self._normalize_message_id(email_data.get('message_id'))
|
||||||
|
|
||||||
def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]:
|
def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]:
|
||||||
@ -326,11 +367,14 @@ class EmailWorkflowService:
|
|||||||
FROM sag_emails se
|
FROM sag_emails se
|
||||||
JOIN email_messages em ON em.id = se.email_id
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
WHERE em.deleted_at IS NULL
|
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
|
||||||
|
OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) = %s
|
||||||
|
)
|
||||||
ORDER BY se.created_at DESC
|
ORDER BY se.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(thread_key,)
|
(thread_key, thread_key)
|
||||||
)
|
)
|
||||||
return rows[0]['sag_id'] if rows else None
|
return rows[0]['sag_id'] if rows else None
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -356,11 +400,23 @@ class EmailWorkflowService:
|
|||||||
)
|
)
|
||||||
return rows[0]['sag_id'] if rows else None
|
return rows[0]['sag_id'] if rows else None
|
||||||
|
|
||||||
|
# Sender domains that should never trigger customer-domain SAG creation.
|
||||||
|
# Includes own sending domain and common automated senders.
|
||||||
|
_IGNORED_SENDER_DOMAINS = {
|
||||||
|
'bmcnetworks.dk',
|
||||||
|
'bmchub.local',
|
||||||
|
}
|
||||||
|
|
||||||
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
|
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
|
||||||
if not domain:
|
if not domain:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
domain = domain.lower().strip()
|
domain = domain.lower().strip()
|
||||||
|
|
||||||
|
# Never match the system's own sending domain as a customer
|
||||||
|
if domain in self._IGNORED_SENDER_DOMAINS:
|
||||||
|
return None
|
||||||
|
|
||||||
domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}"
|
domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}"
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
@ -377,6 +433,114 @@ class EmailWorkflowService:
|
|||||||
rows = execute_query(query, (domain, domain_alt))
|
rows = execute_query(query, (domain, domain_alt))
|
||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
def _find_thread_key_by_bmc_suffix(self, sag_id: int, thread_suffix: str) -> Optional[str]:
|
||||||
|
"""Find the thread_key of an outgoing email whose BMCid matches s{sag_id}t{thread_suffix}."""
|
||||||
|
try:
|
||||||
|
# Legacy compatibility: older outbound emails used t001 when the
|
||||||
|
# provisional thread key was unknown. In that case, pick the most
|
||||||
|
# recent outbound thread key in the same case as best effort.
|
||||||
|
if str(thread_suffix) == '001':
|
||||||
|
fallback = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT em.thread_key
|
||||||
|
FROM sag_emails se
|
||||||
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
|
WHERE se.sag_id = %s
|
||||||
|
AND em.deleted_at IS NULL
|
||||||
|
AND em.thread_key IS NOT NULL
|
||||||
|
AND TRIM(em.thread_key) != ''
|
||||||
|
AND LOWER(COALESCE(em.sender_email, '')) = %s
|
||||||
|
ORDER BY em.received_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(sag_id, 'noreply@bmcnetworks.dk'),
|
||||||
|
)
|
||||||
|
if fallback and fallback[0].get('thread_key'):
|
||||||
|
return fallback[0]['thread_key']
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT em.thread_key
|
||||||
|
FROM sag_emails se
|
||||||
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
|
WHERE se.sag_id = %s
|
||||||
|
AND em.deleted_at IS NULL
|
||||||
|
AND em.thread_key IS NOT NULL
|
||||||
|
AND TRIM(em.thread_key) != ''
|
||||||
|
ORDER BY em.received_date DESC
|
||||||
|
""",
|
||||||
|
(sag_id,),
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Rebuild the BMCid suffix for each candidate thread_key
|
||||||
|
# and return the one that matches our target suffix.
|
||||||
|
for row in rows:
|
||||||
|
tk = row['thread_key']
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "", str(tk).lower())
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()
|
||||||
|
candidate_suffix = str((int(digest[:8], 16) % 900000) + 100000)
|
||||||
|
if candidate_suffix == thread_suffix:
|
||||||
|
return tk
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("⚠️ Failed BMCid thread_key lookup: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_email_thread_key(self, email_id: int, thread_key: str) -> None:
|
||||||
|
"""Set the thread_key on an email so it groups correctly."""
|
||||||
|
execute_update(
|
||||||
|
"UPDATE email_messages SET thread_key = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(thread_key, email_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _finalize_sag_routing(
|
||||||
|
self, email_id: int, email_data: Dict, sag_id: int, routing_source: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Link an email to an existing SAG and mark as processed."""
|
||||||
|
case_rows = execute_query(
|
||||||
|
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(sag_id,),
|
||||||
|
)
|
||||||
|
if not case_rows:
|
||||||
|
logger.warning("⚠️ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
|
||||||
|
return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
|
||||||
|
|
||||||
|
case = case_rows[0]
|
||||||
|
self._add_helpdesk_comment(sag_id, email_data)
|
||||||
|
self._link_email_to_sag(sag_id, email_id)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET linked_case_id = %s,
|
||||||
|
customer_id = COALESCE(customer_id, %s),
|
||||||
|
status = 'processed',
|
||||||
|
folder = 'Processed',
|
||||||
|
processed_at = CURRENT_TIMESTAMP,
|
||||||
|
auto_processed = true
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(sag_id, case.get('customer_id'), email_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
token_for_attach = None
|
||||||
|
token_route = self._resolve_scan_token_route(email_id, email_data)
|
||||||
|
if token_route:
|
||||||
|
token_for_attach = token_route.get('token')
|
||||||
|
self._auto_attach_scanner_email(email_id, sag_id, token_for_attach)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'completed',
|
||||||
|
'action': 'updated_existing_sag',
|
||||||
|
'sag_id': sag_id,
|
||||||
|
'customer_id': case.get('customer_id'),
|
||||||
|
'routing_source': routing_source,
|
||||||
|
}
|
||||||
|
|
||||||
def _link_email_to_sag(self, sag_id: int, email_id: int) -> None:
|
def _link_email_to_sag(self, sag_id: int, email_id: int) -> None:
|
||||||
execute_update(
|
execute_update(
|
||||||
"""
|
"""
|
||||||
@ -389,6 +553,379 @@ class EmailWorkflowService:
|
|||||||
(sag_id, email_id, sag_id, email_id)
|
(sag_id, email_id, sag_id, email_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _extract_scan_tokens(self, *values: Optional[str]) -> List[str]:
|
||||||
|
tokens: List[str] = []
|
||||||
|
for value in values:
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
found = self._SCAN_TOKEN_PATTERN.findall(str(value))
|
||||||
|
if found:
|
||||||
|
tokens.extend(token.upper() for token in found)
|
||||||
|
return list(dict.fromkeys(tokens))
|
||||||
|
|
||||||
|
def _resolve_scan_token_route(self, email_id: int, email_data: Dict) -> Optional[Dict[str, Any]]:
|
||||||
|
text_tokens = self._extract_scan_tokens(
|
||||||
|
email_data.get('subject'),
|
||||||
|
email_data.get('body_text'),
|
||||||
|
email_data.get('body_html'),
|
||||||
|
email_data.get('in_reply_to'),
|
||||||
|
email_data.get('email_references'),
|
||||||
|
)
|
||||||
|
|
||||||
|
filename_tokens: List[str] = []
|
||||||
|
attachment_content_tokens: List[str] = []
|
||||||
|
try:
|
||||||
|
attachment_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT filename, content_type, content_data, file_path
|
||||||
|
FROM email_attachments
|
||||||
|
WHERE email_id = %s
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(email_id,),
|
||||||
|
) or []
|
||||||
|
for row in attachment_rows:
|
||||||
|
filename_tokens.extend(self._extract_scan_tokens(row.get('filename')))
|
||||||
|
attachment_content_tokens.extend(
|
||||||
|
self._extract_scan_tokens_from_attachment(
|
||||||
|
filename=row.get('filename'),
|
||||||
|
content_type=row.get('content_type'),
|
||||||
|
content_data=row.get('content_data'),
|
||||||
|
file_path=row.get('file_path'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("⚠️ Failed to inspect attachment filenames for scan token: %s", exc)
|
||||||
|
|
||||||
|
all_tokens = list(dict.fromkeys(text_tokens + filename_tokens + attachment_content_tokens))
|
||||||
|
if not all_tokens:
|
||||||
|
return self._resolve_scan_route_from_scanner_headers(email_data)
|
||||||
|
|
||||||
|
placeholders = ','.join(['%s'] * len(all_tokens))
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT token, sag_id, token_type
|
||||||
|
FROM sag_document_tokens
|
||||||
|
WHERE token IN ({placeholders})
|
||||||
|
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY consumed_at IS NULL DESC, created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
tuple(all_tokens),
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
# Fallback for scanner workflows where token only exists in barcode image
|
||||||
|
# and therefore not in plain text metadata.
|
||||||
|
return self._resolve_scan_route_from_scanner_headers(email_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("⚠️ Scan token lookup failed: %s", exc)
|
||||||
|
return self._resolve_scan_route_from_scanner_headers(email_data)
|
||||||
|
|
||||||
|
def _extract_scan_tokens_from_attachment(
|
||||||
|
self,
|
||||||
|
filename: Optional[str],
|
||||||
|
content_type: Optional[str],
|
||||||
|
content_data: Optional[Any],
|
||||||
|
file_path: Optional[str],
|
||||||
|
) -> List[str]:
|
||||||
|
tokens: List[str] = []
|
||||||
|
|
||||||
|
payload: Optional[bytes] = None
|
||||||
|
if content_data is not None:
|
||||||
|
try:
|
||||||
|
payload = bytes(content_data)
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
|
||||||
|
if payload is None and file_path:
|
||||||
|
try:
|
||||||
|
payload = Path(file_path).read_bytes()
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
# 1) Cheap text extraction directly from bytes catches tokens in OCR-layer PDFs,
|
||||||
|
# plain text files, or metadata-rich attachments.
|
||||||
|
try:
|
||||||
|
sample = payload[:1_500_000]
|
||||||
|
tokens.extend(self._extract_scan_tokens(sample.decode('utf-8', errors='ignore')))
|
||||||
|
tokens.extend(self._extract_scan_tokens(sample.decode('latin-1', errors='ignore')))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ext = (Path(str(filename or '')).suffix or '').lower().strip('.')
|
||||||
|
ctype = (content_type or '').lower()
|
||||||
|
|
||||||
|
# 2) PDF text-layer extraction (when available) for scanned documents with OCR.
|
||||||
|
if ext == 'pdf' or 'pdf' in ctype:
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader # type: ignore
|
||||||
|
|
||||||
|
reader = PdfReader(io.BytesIO(payload))
|
||||||
|
text_chunks: List[str] = []
|
||||||
|
for page in reader.pages[:5]:
|
||||||
|
extracted = page.extract_text() or ''
|
||||||
|
if extracted:
|
||||||
|
text_chunks.append(extracted)
|
||||||
|
if text_chunks:
|
||||||
|
tokens.extend(self._extract_scan_tokens("\n".join(text_chunks)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3) Decode barcode directly from scanned attachments.
|
||||||
|
# This catches cases where BMCSCAN exists only as a barcode image.
|
||||||
|
try:
|
||||||
|
if ext == 'pdf' or 'pdf' in ctype:
|
||||||
|
tokens.extend(self._extract_scan_tokens_from_pdf_barcode(payload))
|
||||||
|
else:
|
||||||
|
tokens.extend(self._extract_scan_tokens_from_image_barcode(payload))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return list(dict.fromkeys(token.upper() for token in tokens if token))
|
||||||
|
|
||||||
|
def _extract_scan_tokens_from_image_barcode(self, payload: bytes) -> List[str]:
|
||||||
|
try:
|
||||||
|
from PIL import Image # type: ignore
|
||||||
|
from pyzbar.pyzbar import decode as zbar_decode # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = Image.open(io.BytesIO(payload))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
decoded_tokens: List[str] = []
|
||||||
|
variants = [image]
|
||||||
|
try:
|
||||||
|
variants.append(image.convert('L'))
|
||||||
|
variants.append(image.convert('L').point(lambda p: 255 if p > 140 else 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for variant in variants:
|
||||||
|
try:
|
||||||
|
for item in zbar_decode(variant):
|
||||||
|
raw = item.data.decode('utf-8', errors='ignore')
|
||||||
|
decoded_tokens.extend(self._extract_scan_tokens(raw))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return list(dict.fromkeys(decoded_tokens))
|
||||||
|
|
||||||
|
def _extract_scan_tokens_from_pdf_barcode(self, payload: bytes) -> List[str]:
|
||||||
|
try:
|
||||||
|
import pypdfium2 as pdfium # type: ignore
|
||||||
|
from pyzbar.pyzbar import decode as zbar_decode # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
decoded_tokens: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = pdfium.PdfDocument(io.BytesIO(payload))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
page_count = min(len(doc), 3)
|
||||||
|
for page_index in range(page_count):
|
||||||
|
page = None
|
||||||
|
try:
|
||||||
|
page = doc.get_page(page_index)
|
||||||
|
bitmap = page.render(scale=2.2)
|
||||||
|
pil_image = bitmap.to_pil()
|
||||||
|
|
||||||
|
for variant in (pil_image, pil_image.convert('L')):
|
||||||
|
for item in zbar_decode(variant):
|
||||||
|
raw = item.data.decode('utf-8', errors='ignore')
|
||||||
|
decoded_tokens.extend(self._extract_scan_tokens(raw))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if page is not None:
|
||||||
|
page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return list(dict.fromkeys(decoded_tokens))
|
||||||
|
|
||||||
|
def _resolve_scan_route_from_scanner_headers(self, email_data: Dict) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Infer case route from scanner-generated message-id timestamps.
|
||||||
|
|
||||||
|
Some scanner/MFP flows only include the barcode token inside the attached image/PDF,
|
||||||
|
while headers contain a timestamped local message-id such as
|
||||||
|
`<1.20260401075731@172.16.31.35>`. We map that timestamp to the nearest recent,
|
||||||
|
unconsumed document token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
header_values = [
|
||||||
|
email_data.get('in_reply_to'),
|
||||||
|
email_data.get('email_references'),
|
||||||
|
email_data.get('message_id'),
|
||||||
|
email_data.get('thread_key'),
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates: List[datetime] = []
|
||||||
|
ts_pattern = re.compile(r'(20\d{12})')
|
||||||
|
|
||||||
|
for raw in header_values:
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
for match in ts_pattern.findall(str(raw)):
|
||||||
|
try:
|
||||||
|
candidates.append(datetime.strptime(match, "%Y%m%d%H%M%S"))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for ts in candidates:
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT token, sag_id, token_type, created_at
|
||||||
|
FROM sag_document_tokens
|
||||||
|
WHERE consumed_at IS NULL
|
||||||
|
AND created_at BETWEEN %s::timestamp - INTERVAL '90 minutes'
|
||||||
|
AND %s::timestamp + INTERVAL '20 minutes'
|
||||||
|
ORDER BY ABS(EXTRACT(EPOCH FROM (created_at - %s::timestamp))) ASC,
|
||||||
|
CASE WHEN token_type = 'work_order' THEN 0 ELSE 1 END,
|
||||||
|
id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(ts, ts, ts),
|
||||||
|
) or []
|
||||||
|
if rows:
|
||||||
|
row = rows[0]
|
||||||
|
logger.info(
|
||||||
|
"🔎 Inferred scanner route via header timestamp %s -> SAG-%s (%s)",
|
||||||
|
ts.isoformat(),
|
||||||
|
row.get('sag_id'),
|
||||||
|
row.get('token'),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'token': row.get('token'),
|
||||||
|
'sag_id': row.get('sag_id'),
|
||||||
|
'token_type': row.get('token_type'),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("⚠️ Scanner header timestamp route lookup failed: %s", exc)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _copy_email_attachments_to_case(self, email_id: int, sag_id: int, source_token: Optional[str]) -> int:
|
||||||
|
attachments = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT filename, content_type, size_bytes, file_path, content_data
|
||||||
|
FROM email_attachments
|
||||||
|
WHERE email_id = %s
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(email_id,),
|
||||||
|
) or []
|
||||||
|
if not attachments:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
upload_base = Path(settings.UPLOAD_DIR).resolve()
|
||||||
|
(upload_base / "sag_files").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
has_source_email = table_has_column("sag_files", "source_email_id")
|
||||||
|
has_source_type = table_has_column("sag_files", "source_type")
|
||||||
|
has_source_token = table_has_column("sag_files", "source_token")
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
for attachment in attachments:
|
||||||
|
filename = Path(attachment.get('filename') or 'scanned-document.bin').name
|
||||||
|
|
||||||
|
if has_source_email:
|
||||||
|
existing = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_files
|
||||||
|
WHERE sag_id = %s
|
||||||
|
AND source_email_id = %s
|
||||||
|
AND filename = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(sag_id, email_id, filename),
|
||||||
|
) or []
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = attachment.get('content_data')
|
||||||
|
if payload is None and attachment.get('file_path'):
|
||||||
|
try:
|
||||||
|
payload = Path(attachment['file_path']).read_bytes()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("⚠️ Could not read attachment file (%s): %s", filename, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_payload = bytes(payload)
|
||||||
|
stored_name = f"sag_files/{uuid4().hex}_{filename}"
|
||||||
|
target_path = upload_base / stored_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_path.write_bytes(raw_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("⚠️ Could not write case file from attachment (%s): %s", filename, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
|
||||||
|
values: List[Any] = [
|
||||||
|
sag_id,
|
||||||
|
filename,
|
||||||
|
attachment.get('content_type') or 'application/octet-stream',
|
||||||
|
attachment.get('size_bytes') or len(raw_payload),
|
||||||
|
stored_name,
|
||||||
|
]
|
||||||
|
if has_source_email:
|
||||||
|
columns.append("source_email_id")
|
||||||
|
values.append(email_id)
|
||||||
|
if has_source_type:
|
||||||
|
columns.append("source_type")
|
||||||
|
values.append("scanner_email")
|
||||||
|
if has_source_token:
|
||||||
|
columns.append("source_token")
|
||||||
|
values.append(source_token)
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
|
||||||
|
tuple(values),
|
||||||
|
)
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
return copied
|
||||||
|
|
||||||
|
def _auto_attach_scanner_email(self, email_id: int, sag_id: int, token: Optional[str]) -> None:
|
||||||
|
try:
|
||||||
|
copied = self._copy_email_attachments_to_case(email_id, sag_id, token)
|
||||||
|
if copied > 0:
|
||||||
|
logger.info("📎 Auto-attached %s attachment(s) from email %s to SAG-%s", copied, email_id, sag_id)
|
||||||
|
|
||||||
|
if token:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE sag_document_tokens
|
||||||
|
SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
|
||||||
|
consumed_email_id = COALESCE(consumed_email_id, %s)
|
||||||
|
WHERE token = %s
|
||||||
|
""",
|
||||||
|
(email_id, token),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("⚠️ Scanner auto-attach failed for email %s: %s", email_id, exc)
|
||||||
|
|
||||||
def _strip_quoted_email_text(self, body_text: str) -> str:
|
def _strip_quoted_email_text(self, body_text: str) -> str:
|
||||||
"""Return only the newest reply content (remove quoted history/signatures)."""
|
"""Return only the newest reply content (remove quoted history/signatures)."""
|
||||||
if not body_text:
|
if not body_text:
|
||||||
@ -490,6 +1027,41 @@ class EmailWorkflowService:
|
|||||||
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key)
|
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key)
|
||||||
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data)
|
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data)
|
||||||
sag_id_from_tag = self._extract_sag_id(email_data)
|
sag_id_from_tag = self._extract_sag_id(email_data)
|
||||||
|
scan_token_route = self._resolve_scan_token_route(email_id, email_data)
|
||||||
|
|
||||||
|
if scan_token_route and scan_token_route.get('sag_id'):
|
||||||
|
matched_sag_id = int(scan_token_route['sag_id'])
|
||||||
|
logger.info("🔎 Scan token matched email %s to SAG-%s", email_id, matched_sag_id)
|
||||||
|
return await self._finalize_sag_routing(email_id, email_data, matched_sag_id, 'scan_token')
|
||||||
|
|
||||||
|
# Priority 0: BMCid is the most reliable signal — it's our own hidden
|
||||||
|
# marker embedded in every outgoing case email. When present, it
|
||||||
|
# provides the sag_id directly and the thread_suffix lets us adopt
|
||||||
|
# the correct thread_key for multi-thread SAGs.
|
||||||
|
bmc_id = self._extract_bmc_id(email_data)
|
||||||
|
if bmc_id:
|
||||||
|
bmc_sag_id = bmc_id['sag_id']
|
||||||
|
bmc_thread_suffix = bmc_id['thread_suffix']
|
||||||
|
# Look up the thread_key of the outgoing email whose BMCid matches
|
||||||
|
bmc_thread_key = self._find_thread_key_by_bmc_suffix(bmc_sag_id, bmc_thread_suffix)
|
||||||
|
if bmc_thread_key:
|
||||||
|
# Adopt the outgoing email's thread_key so reply groups correctly
|
||||||
|
self._update_email_thread_key(email_id, bmc_thread_key)
|
||||||
|
logger.info(
|
||||||
|
"🔖 BMCid s%st%s matched → SAG-%s (thread_key=%s)",
|
||||||
|
bmc_sag_id, bmc_thread_suffix, bmc_sag_id, bmc_thread_key,
|
||||||
|
)
|
||||||
|
sag_id = bmc_sag_id
|
||||||
|
routing_source = 'bmc_id'
|
||||||
|
# Skip the remaining priority chain — BMCid is authoritative
|
||||||
|
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
|
||||||
|
|
||||||
|
# Fallback: try the explicit provider thread key (e.g. Graph conversationId)
|
||||||
|
# separately when the derived key (References[0]) differs from it.
|
||||||
|
provider_thread_key = self._normalize_message_id(email_data.get('thread_key'))
|
||||||
|
sag_id_from_provider = None
|
||||||
|
if provider_thread_key and provider_thread_key != derived_thread_key:
|
||||||
|
sag_id_from_provider = self._find_sag_id_from_thread_key(provider_thread_key)
|
||||||
|
|
||||||
routing_source = None
|
routing_source = None
|
||||||
sag_id = None
|
sag_id = None
|
||||||
@ -512,6 +1084,11 @@ class EmailWorkflowService:
|
|||||||
routing_source = 'thread_headers'
|
routing_source = 'thread_headers'
|
||||||
logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id)
|
logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id)
|
||||||
|
|
||||||
|
if sag_id_from_provider and not sag_id:
|
||||||
|
sag_id = sag_id_from_provider
|
||||||
|
routing_source = 'provider_thread_key'
|
||||||
|
logger.info("🧵 Matched email %s to SAG-%s via provider thread key (conversationId)", email_id, sag_id)
|
||||||
|
|
||||||
if sag_id_from_tag:
|
if sag_id_from_tag:
|
||||||
if sag_id and sag_id != sag_id_from_tag:
|
if sag_id and sag_id != sag_id_from_tag:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -527,40 +1104,7 @@ class EmailWorkflowService:
|
|||||||
|
|
||||||
# 1) Existing SAG via subject/headers
|
# 1) Existing SAG via subject/headers
|
||||||
if sag_id:
|
if sag_id:
|
||||||
case_rows = execute_query(
|
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
|
||||||
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
|
||||||
(sag_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not case_rows:
|
|
||||||
logger.warning("⚠️ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
|
|
||||||
return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
|
|
||||||
|
|
||||||
case = case_rows[0]
|
|
||||||
self._add_helpdesk_comment(sag_id, email_data)
|
|
||||||
self._link_email_to_sag(sag_id, email_id)
|
|
||||||
|
|
||||||
execute_update(
|
|
||||||
"""
|
|
||||||
UPDATE email_messages
|
|
||||||
SET linked_case_id = %s,
|
|
||||||
customer_id = COALESCE(customer_id, %s),
|
|
||||||
status = 'processed',
|
|
||||||
folder = 'Processed',
|
|
||||||
processed_at = CURRENT_TIMESTAMP,
|
|
||||||
auto_processed = true
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(sag_id, case.get('customer_id'), email_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'action': 'updated_existing_sag',
|
|
||||||
'sag_id': sag_id,
|
|
||||||
'customer_id': case.get('customer_id'),
|
|
||||||
'routing_source': routing_source
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2) No SAG id -> create only if sender domain belongs to known customer
|
# 2) No SAG id -> create only if sender domain belongs to known customer
|
||||||
sender_domain = self._extract_sender_domain(email_data)
|
sender_domain = self._extract_sender_domain(email_data)
|
||||||
@ -588,6 +1132,7 @@ class EmailWorkflowService:
|
|||||||
(case['id'], customer['id'], email_id)
|
(case['id'], customer['id'], email_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._auto_attach_scanner_email(email_id, case['id'], None)
|
||||||
logger.info("✅ Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id'])
|
logger.info("✅ Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id'])
|
||||||
return {
|
return {
|
||||||
'status': 'completed',
|
'status': 'completed',
|
||||||
|
|||||||
@ -102,7 +102,7 @@ class ReminderNotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get user email
|
# Get user email
|
||||||
user_query = "SELECT email FROM users WHERE id = %s"
|
user_query = "SELECT email FROM users WHERE user_id = %s"
|
||||||
user = execute_query(user_query, (user_id,))
|
user = execute_query(user_query, (user_id,))
|
||||||
user_email = user[0]['email'] if user else None
|
user_email = user[0]['email'] if user else None
|
||||||
|
|
||||||
|
|||||||
185
app/services/vaultwarden_service.py
Normal file
185
app/services/vaultwarden_service.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VaultwardenServiceError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _is_configured() -> bool:
|
||||||
|
return bool((settings.VAULTWARDEN_BASE_URL or "").strip()) and bool((settings.VAULTWARDEN_API_TOKEN or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
return (settings.VAULTWARDEN_BASE_URL or "").strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> Dict[str, str]:
|
||||||
|
token = (settings.VAULTWARDEN_API_TOKEN or "").strip()
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-API-Token": token,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_from_cipher(payload: dict) -> Optional[dict]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
login = payload.get("login") or payload.get("Login") or {}
|
||||||
|
if not isinstance(login, dict):
|
||||||
|
login = {}
|
||||||
|
|
||||||
|
username = login.get("username") or login.get("Username")
|
||||||
|
password = login.get("password") or login.get("Password")
|
||||||
|
totp = login.get("totp") or login.get("Totp")
|
||||||
|
|
||||||
|
uris = login.get("uris") or login.get("Uris") or []
|
||||||
|
url = None
|
||||||
|
if isinstance(uris, list) and uris:
|
||||||
|
first = uris[0] or {}
|
||||||
|
if isinstance(first, dict):
|
||||||
|
url = first.get("uri") or first.get("Uri")
|
||||||
|
|
||||||
|
if not any([username, password, totp, url, payload.get("notes") or payload.get("Notes")]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"item_id": str(payload.get("id") or payload.get("Id") or "") or None,
|
||||||
|
"item_name": payload.get("name") or payload.get("Name"),
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"totp": totp,
|
||||||
|
"notes": payload.get("notes") or payload.get("Notes"),
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_from_custom_payload(payload: Any) -> Optional[dict]:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
direct = {
|
||||||
|
"item_id": payload.get("item_id") or payload.get("id"),
|
||||||
|
"item_name": payload.get("item_name") or payload.get("name"),
|
||||||
|
"username": payload.get("username"),
|
||||||
|
"password": payload.get("password"),
|
||||||
|
"totp": payload.get("totp") or payload.get("otp"),
|
||||||
|
"notes": payload.get("notes"),
|
||||||
|
"url": payload.get("url"),
|
||||||
|
}
|
||||||
|
if any(direct.values()):
|
||||||
|
return direct
|
||||||
|
|
||||||
|
nested = payload.get("data")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
nested_res = _extract_from_custom_payload(nested)
|
||||||
|
if nested_res:
|
||||||
|
return nested_res
|
||||||
|
|
||||||
|
cipher_res = _extract_from_cipher(payload)
|
||||||
|
if cipher_res:
|
||||||
|
return cipher_res
|
||||||
|
|
||||||
|
if isinstance(payload, list):
|
||||||
|
for item in payload:
|
||||||
|
extracted = _extract_from_custom_payload(item)
|
||||||
|
if extracted:
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_json(client: httpx.AsyncClient, url: str) -> Any:
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
if not response.content:
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_vault_credentials(
|
||||||
|
*,
|
||||||
|
preferred_item_id: Optional[str],
|
||||||
|
fallback_item_ids: List[str],
|
||||||
|
search_hint: Optional[str],
|
||||||
|
) -> dict:
|
||||||
|
if not _is_configured():
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"configured": False,
|
||||||
|
"message": "Vaultwarden er ikke konfigureret.",
|
||||||
|
"checked_item_ids": [],
|
||||||
|
"credential": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
checked_item_ids: List[str] = []
|
||||||
|
item_id_candidates = [preferred_item_id] + list(fallback_item_ids)
|
||||||
|
deduped_candidates: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for item_id in item_id_candidates:
|
||||||
|
candidate = (item_id or "").strip()
|
||||||
|
if not candidate or candidate in seen:
|
||||||
|
continue
|
||||||
|
seen.add(candidate)
|
||||||
|
deduped_candidates.append(candidate)
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(connect=6.0, read=10.0, write=10.0, pool=6.0)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, headers=_headers(), follow_redirects=True) as client:
|
||||||
|
base = _base_url()
|
||||||
|
|
||||||
|
for item_id in deduped_candidates:
|
||||||
|
checked_item_ids.append(item_id)
|
||||||
|
try:
|
||||||
|
payload = await _get_json(client, f"{base}/api/ciphers/{quote(item_id)}")
|
||||||
|
extracted = _extract_from_custom_payload(payload)
|
||||||
|
if extracted:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"configured": True,
|
||||||
|
"message": "Vault-opslag gennemfoert.",
|
||||||
|
"checked_item_ids": checked_item_ids,
|
||||||
|
"credential": extracted,
|
||||||
|
}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("Vaultwarden item lookup failed for id=%s: %s", item_id, exc)
|
||||||
|
|
||||||
|
hint = (search_hint or "").strip()
|
||||||
|
if hint:
|
||||||
|
encoded_hint = quote(hint)
|
||||||
|
search_endpoints = [
|
||||||
|
f"{base}/api/links/credentials?search={encoded_hint}",
|
||||||
|
f"{base}/api/ciphers?search={encoded_hint}",
|
||||||
|
f"{base}/api/ciphers?url={encoded_hint}",
|
||||||
|
]
|
||||||
|
|
||||||
|
for endpoint in search_endpoints:
|
||||||
|
try:
|
||||||
|
payload = await _get_json(client, endpoint)
|
||||||
|
extracted = _extract_from_custom_payload(payload)
|
||||||
|
if extracted:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"configured": True,
|
||||||
|
"message": "Vault-opslag gennemfoert.",
|
||||||
|
"checked_item_ids": checked_item_ids,
|
||||||
|
"credential": extracted,
|
||||||
|
}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.info("Vaultwarden search endpoint failed (%s): %s", endpoint, exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "not_found",
|
||||||
|
"configured": True,
|
||||||
|
"message": "Ingen vault credentials fundet for linket.",
|
||||||
|
"checked_item_ids": checked_item_ids,
|
||||||
|
"credential": None,
|
||||||
|
}
|
||||||
@ -221,6 +221,47 @@ 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
_label_printer_keys = {
|
||||||
|
"label_printer_enabled": ("integrations", "Enable direct label printing", "boolean", True),
|
||||||
|
"label_printer_model": ("integrations", "Brother printer model for direct labels", "string", True),
|
||||||
|
"label_printer_host": ("integrations", "Brother printer host/IP", "string", True),
|
||||||
|
"label_printer_port": ("integrations", "Brother printer TCP port", "integer", True),
|
||||||
|
"label_printer_label_size": ("integrations", "Brother label size code", "string", True),
|
||||||
|
}
|
||||||
|
if not result and key in _label_printer_keys:
|
||||||
|
category, description, value_type, is_public = _label_printer_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.
|
# 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"}:
|
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
|
||||||
defaults = {
|
defaults = {
|
||||||
@ -259,6 +300,14 @@ async def get_setting_categories():
|
|||||||
return [row['category'] for row in result] if result else []
|
return [row['category'] for row in result] if result else []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/migrations/status", tags=["Settings"])
|
||||||
|
async def get_migration_statuses_api():
|
||||||
|
"""Expose migration status via API router (served under /api/v1)."""
|
||||||
|
from app.settings.backend.views import migration_statuses
|
||||||
|
|
||||||
|
return migration_statuses()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/settings/sync-from-env", tags=["Settings"])
|
@router.post("/settings/sync-from-env", tags=["Settings"])
|
||||||
async def sync_settings_from_env():
|
async def sync_settings_from_env():
|
||||||
"""Sync settings from .env file into database (only updates empty values)"""
|
"""Sync settings from .env file into database (only updates empty values)"""
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Settings Frontend Views
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@ -15,6 +16,183 @@ from app.core.database import get_db_connection, release_db_connection, execute_
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
CREATE_TABLE_RE = re.compile(
|
||||||
|
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
ADD_COLUMN_RE = re.compile(
|
||||||
|
r"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
CREATE_INDEX_RE = re.compile(
|
||||||
|
r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s+ON\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
SKIP_COLUMN_LINE_RE = re.compile(
|
||||||
|
r"^(?:CONSTRAINT|PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CASE|WHEN|ELSE|END)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_sql_comments(sql: str) -> str:
|
||||||
|
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
|
||||||
|
sql = re.sub(r"--[^\n]*", "", sql)
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_create_table_block(sql: str, start_pos: int) -> str:
|
||||||
|
open_paren = sql.find("(", start_pos)
|
||||||
|
if open_paren == -1:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
for idx in range(open_paren, len(sql)):
|
||||||
|
ch = sql[idx]
|
||||||
|
if ch == "(":
|
||||||
|
depth += 1
|
||||||
|
elif ch == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return sql[open_paren + 1:idx]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_columns_from_create_block(block: str) -> set[str]:
|
||||||
|
columns: set[str] = set()
|
||||||
|
known_types = {
|
||||||
|
"serial", "bigserial", "smallint", "integer", "bigint", "numeric", "decimal", "real", "double",
|
||||||
|
"varchar", "character", "text", "boolean", "bool", "date", "timestamp", "time", "json", "jsonb", "uuid"
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw_line in block.splitlines():
|
||||||
|
line = raw_line.strip().rstrip(",")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if SKIP_COLUMN_LINE_RE.match(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tokens = line.replace("(", " ").split()
|
||||||
|
if len(tokens) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
second = tokens[1].strip().lower()
|
||||||
|
second_base = re.sub(r"[^a-z]", "", second)
|
||||||
|
if second_base and second_base not in known_types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = re.match(r"^\"?([A-Za-z_][A-Za-z0-9_]*)\"?\s+", line)
|
||||||
|
if match:
|
||||||
|
columns.add(match.group(1))
|
||||||
|
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_migration_expectations(sql: str) -> tuple[set[str], set[tuple[str, str]], set[str]]:
|
||||||
|
expected_tables: set[str] = set()
|
||||||
|
expected_columns: set[tuple[str, str]] = set()
|
||||||
|
expected_indexes: set[str] = set()
|
||||||
|
|
||||||
|
clean_sql = _strip_sql_comments(sql)
|
||||||
|
|
||||||
|
for match in CREATE_TABLE_RE.finditer(clean_sql):
|
||||||
|
table_name = match.group(1)
|
||||||
|
expected_tables.add(table_name)
|
||||||
|
block = _extract_create_table_block(clean_sql, match.end() - 1)
|
||||||
|
for column_name in _parse_columns_from_create_block(block):
|
||||||
|
expected_columns.add((table_name, column_name))
|
||||||
|
|
||||||
|
for match in ADD_COLUMN_RE.finditer(clean_sql):
|
||||||
|
expected_columns.add((match.group(1), match.group(2)))
|
||||||
|
|
||||||
|
for match in CREATE_INDEX_RE.finditer(clean_sql):
|
||||||
|
expected_indexes.add(match.group(1))
|
||||||
|
|
||||||
|
return expected_tables, expected_columns, expected_indexes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_actual_schema_snapshot(conn) -> tuple[set[str], set[tuple[str, str]], set[str]]:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
tables = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
columns = {(row[0], row[1]) for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
indexes = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
return tables, columns, indexes
|
||||||
|
|
||||||
|
|
||||||
|
def _status_for_migration_file(
|
||||||
|
migration_sql: str,
|
||||||
|
actual_tables: set[str],
|
||||||
|
actual_columns: set[tuple[str, str]],
|
||||||
|
actual_indexes: set[str],
|
||||||
|
) -> dict:
|
||||||
|
expected_tables, expected_columns, expected_indexes = _parse_migration_expectations(migration_sql)
|
||||||
|
|
||||||
|
total_checks = len(expected_tables) + len(expected_columns) + len(expected_indexes)
|
||||||
|
if total_checks == 0:
|
||||||
|
return {
|
||||||
|
"status": "gray",
|
||||||
|
"label": "Grå",
|
||||||
|
"summary": "Ingen direkte schema-checks fundet i filen",
|
||||||
|
"missing_tables": [],
|
||||||
|
"missing_columns": [],
|
||||||
|
"missing_indexes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_tables = sorted([tbl for tbl in expected_tables if tbl not in actual_tables])
|
||||||
|
missing_columns = sorted([f"{tbl}.{col}" for (tbl, col) in expected_columns if (tbl, col) not in actual_columns])
|
||||||
|
missing_indexes = sorted([idx for idx in expected_indexes if idx not in actual_indexes])
|
||||||
|
|
||||||
|
if not missing_tables and not missing_columns and not missing_indexes:
|
||||||
|
return {
|
||||||
|
"status": "green",
|
||||||
|
"label": "Grøn",
|
||||||
|
"summary": "Alle schema-elementer fra filen findes i databasen",
|
||||||
|
"missing_tables": [],
|
||||||
|
"missing_columns": [],
|
||||||
|
"missing_indexes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if missing_tables:
|
||||||
|
parts.append(f"tabeller: {len(missing_tables)}")
|
||||||
|
if missing_columns:
|
||||||
|
parts.append(f"kolonner: {len(missing_columns)}")
|
||||||
|
if missing_indexes:
|
||||||
|
parts.append(f"indexes: {len(missing_indexes)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "red",
|
||||||
|
"label": "Rød",
|
||||||
|
"summary": "Mangler " + ", ".join(parts),
|
||||||
|
"missing_tables": missing_tables,
|
||||||
|
"missing_columns": missing_columns,
|
||||||
|
"missing_indexes": missing_indexes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
|
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
|
||||||
async def settings_page(request: Request):
|
async def settings_page(request: Request):
|
||||||
@ -73,6 +251,36 @@ class MigrationExecution(BaseModel):
|
|||||||
file_name: str
|
file_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/migrations/status", tags=["Frontend"])
|
||||||
|
def migration_statuses():
|
||||||
|
"""Check migration files against current schema and return per-file color status."""
|
||||||
|
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
|
||||||
|
files = sorted(migrations_dir.glob("*.sql")) if migrations_dir.exists() else []
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
|
||||||
|
statuses = []
|
||||||
|
for migration_file in files:
|
||||||
|
migration_sql = migration_file.read_text(encoding="utf-8")
|
||||||
|
status_info = _status_for_migration_file(
|
||||||
|
migration_sql,
|
||||||
|
actual_tables,
|
||||||
|
actual_columns,
|
||||||
|
actual_indexes,
|
||||||
|
)
|
||||||
|
statuses.append({
|
||||||
|
"name": migration_file.name,
|
||||||
|
**status_info,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"statuses": statuses}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Status check failed: {exc}")
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/settings/migrations/execute", tags=["Frontend"])
|
@router.post("/settings/migrations/execute", tags=["Frontend"])
|
||||||
def execute_migration(payload: MigrationExecution):
|
def execute_migration(payload: MigrationExecution):
|
||||||
"""Execute a migration SQL file"""
|
"""Execute a migration SQL file"""
|
||||||
|
|||||||
@ -20,6 +20,11 @@
|
|||||||
.command-actions .btn {
|
.command-actions .btn {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
.migration-status-badge {
|
||||||
|
min-width: 72px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -45,7 +50,12 @@
|
|||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-header bg-white">
|
<div class="card-header bg-white">
|
||||||
<h6 class="mb-0 fw-bold"><i class="bi bi-database me-2"></i>Tilgængelige migrationer</h6>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 fw-bold"><i class="bi bi-database me-2"></i>Tilgængelige migrationer</h6>
|
||||||
|
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
|
||||||
|
<i class="bi bi-check2-circle me-1"></i>Tjek status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if migrations and migrations|length > 0 %}
|
{% if migrations and migrations|length > 0 %}
|
||||||
@ -54,6 +64,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Fil</th>
|
<th>Fil</th>
|
||||||
|
<th>Status</th>
|
||||||
<th>Størrelse</th>
|
<th>Størrelse</th>
|
||||||
<th>Sidst ændret</th>
|
<th>Sidst ændret</th>
|
||||||
<th class="text-end">Handling</th>
|
<th class="text-end">Handling</th>
|
||||||
@ -65,6 +76,9 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>{{ migration.name }}</strong>
|
<strong>{{ migration.name }}</strong>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary migration-status-badge" data-migration="{{ migration.name }}" title="Ikke tjekket endnu">Grå</span>
|
||||||
|
</td>
|
||||||
<td>{{ migration.size_kb }} KB</td>
|
<td>{{ migration.size_kb }} KB</td>
|
||||||
<td>{{ migration.modified }}</td>
|
<td>{{ migration.modified }}</td>
|
||||||
<td class="text-end d-flex gap-2 justify-content-end">
|
<td class="text-end d-flex gap-2 justify-content-end">
|
||||||
@ -159,7 +173,6 @@
|
|||||||
|
|
||||||
async function runMigration(migrationName, button) {
|
async function runMigration(migrationName, button) {
|
||||||
const feedback = document.getElementById('migrationFeedback');
|
const feedback = document.getElementById('migrationFeedback');
|
||||||
const url = '/settings/migrations/execute';
|
|
||||||
|
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
feedback.className = 'alert alert-info mt-3';
|
feedback.className = 'alert alert-info mt-3';
|
||||||
@ -167,14 +180,38 @@
|
|||||||
feedback.classList.remove('d-none');
|
feedback.classList.remove('d-none');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const urls = buildMigrationActionUrls('execute');
|
||||||
method: 'POST',
|
const attempts = [];
|
||||||
headers: { 'Content-Type': 'application/json' },
|
let data = null;
|
||||||
body: JSON.stringify({ file_name: migrationName })
|
let lastError = null;
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
for (const url of urls) {
|
||||||
if (!response.ok) throw new Error(data.detail || data.message);
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_name: migrationName })
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
attempts.push(`${url} -> ${response.status}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
data = payload;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = payload.detail || payload.message || `HTTP ${response.status}`;
|
||||||
|
} catch (err) {
|
||||||
|
attempts.push(`${url} -> ERR`);
|
||||||
|
lastError = err.message || 'Netvaerksfejl';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error(`${lastError || 'Migration fejlede'} (forsøgt: ${attempts.join(' | ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
feedback.className = 'alert alert-success mt-3';
|
feedback.className = 'alert alert-success mt-3';
|
||||||
feedback.innerHTML = `<strong>Migration kørt</strong><br><pre class="mb-0">${data.output}</pre>`;
|
feedback.innerHTML = `<strong>Migration kørt</strong><br><pre class="mb-0">${data.output}</pre>`;
|
||||||
@ -185,5 +222,112 @@
|
|||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(migrationName) {
|
||||||
|
const badges = document.querySelectorAll('.migration-status-badge');
|
||||||
|
for (const badge of badges) {
|
||||||
|
if (badge.dataset.migration === migrationName) {
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMigrationStatus(statusItem) {
|
||||||
|
const badge = getStatusBadge(statusItem.name);
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
badge.classList.remove('bg-secondary', 'bg-success', 'bg-danger');
|
||||||
|
|
||||||
|
if (statusItem.status === 'green') {
|
||||||
|
badge.classList.add('bg-success');
|
||||||
|
badge.textContent = 'Grøn';
|
||||||
|
} else if (statusItem.status === 'red') {
|
||||||
|
badge.classList.add('bg-danger');
|
||||||
|
badge.textContent = 'Rød';
|
||||||
|
} else {
|
||||||
|
badge.classList.add('bg-secondary');
|
||||||
|
badge.textContent = 'Grå';
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.title = statusItem.summary || 'Ingen detaljer';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueUrls(urls) {
|
||||||
|
const seen = new Set();
|
||||||
|
return urls.filter((url) => {
|
||||||
|
if (seen.has(url)) return false;
|
||||||
|
seen.add(url);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMigrationActionUrls(action) {
|
||||||
|
const path = (window.location.pathname || '').replace(/\/+$/, '');
|
||||||
|
const dynamicBase = path.endsWith('/migrations') ? path : '/settings/migrations';
|
||||||
|
const candidates = [
|
||||||
|
`${dynamicBase}/${action}`,
|
||||||
|
`/settings/migrations/${action}`,
|
||||||
|
`/api/v1/settings/migrations/${action}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dynamicBase.startsWith('/api/v1/')) {
|
||||||
|
candidates.unshift(`/api/v1/settings/migrations/${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueUrls(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkMigrationStatuses() {
|
||||||
|
const button = document.getElementById('checkMigrationStatusBtn');
|
||||||
|
const feedback = document.getElementById('migrationFeedback');
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
feedback.className = 'alert alert-info mt-3';
|
||||||
|
feedback.textContent = 'Tjekker migration status...';
|
||||||
|
feedback.classList.remove('d-none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urls = buildMigrationActionUrls('status');
|
||||||
|
let data = null;
|
||||||
|
let lastError = null;
|
||||||
|
const attempts = [];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { credentials: 'include' });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
attempts.push(`${url} -> ${response.status}`);
|
||||||
|
if (response.ok) {
|
||||||
|
data = payload;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastError = payload.detail || `HTTP ${response.status}`;
|
||||||
|
} catch (err) {
|
||||||
|
attempts.push(`${url} -> ERR`);
|
||||||
|
lastError = err.message || 'Netvaerksfejl';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error(`${lastError || 'Status check fejlede'} (forsøgt: ${attempts.join(' | ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = data.statuses || [];
|
||||||
|
statuses.forEach(applyMigrationStatus);
|
||||||
|
|
||||||
|
const redCount = statuses.filter(item => item.status === 'red').length;
|
||||||
|
const greenCount = statuses.filter(item => item.status === 'green').length;
|
||||||
|
const grayCount = statuses.filter(item => item.status === 'gray').length;
|
||||||
|
|
||||||
|
feedback.className = redCount > 0 ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
|
||||||
|
feedback.textContent = `Status opdateret: ${greenCount} grøn, ${redCount} rød, ${grayCount} grå.`;
|
||||||
|
} catch (error) {
|
||||||
|
feedback.className = 'alert alert-danger mt-3';
|
||||||
|
feedback.textContent = `Fejl ved status check: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -204,6 +204,103 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 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-printer" style="font-size:1.4rem;color:#0f4c75"></i>
|
||||||
|
<h5 class="mb-0 fw-bold">Brother Label Printer (Direkte print)</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold">Aktiver</label>
|
||||||
|
<div class="form-check form-switch mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="labelPrinterEnabled" role="switch">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold">Model</label>
|
||||||
|
<input type="text" class="form-control" id="labelPrinterModel" placeholder="QL-710W" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Printer IP / Host</label>
|
||||||
|
<input type="text" class="form-control" id="labelPrinterHost" placeholder="172.16.31.32" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label fw-semibold">Port</label>
|
||||||
|
<input type="number" class="form-control" id="labelPrinterPort" min="1" max="65535" value="9100">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold">Label størrelse</label>
|
||||||
|
<input type="text" class="form-control" id="labelPrinterSize" placeholder="62" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<small class="text-muted">Tip: QL-710W bruger typisk port 9100. Label-størrelse kan fx være <strong>62</strong>.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 mt-4">
|
||||||
|
<button class="btn btn-primary" onclick="saveLabelPrinterSettings()">
|
||||||
|
<i class="bi bi-save me-2"></i>Gem label printer
|
||||||
|
</button>
|
||||||
|
<span id="labelPrinterSaveStatus" class="small text-muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Telefoni -->
|
<!-- Telefoni -->
|
||||||
@ -1990,6 +2087,8 @@ async function loadSettings() {
|
|||||||
await loadCaseStatusesSetting();
|
await loadCaseStatusesSetting();
|
||||||
await loadTagsManagement();
|
await loadTagsManagement();
|
||||||
await loadNextcloudInstances();
|
await loadNextcloudInstances();
|
||||||
|
await loadAnydeskSettings();
|
||||||
|
await loadLabelPrinterSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
}
|
}
|
||||||
@ -2031,6 +2130,158 @@ function displaySettingsByCategory() {
|
|||||||
displaySettings('systemSettings', categories.system);
|
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 loadLabelPrinterSettings() {
|
||||||
|
const keys = [
|
||||||
|
'label_printer_enabled',
|
||||||
|
'label_printer_model',
|
||||||
|
'label_printer_host',
|
||||||
|
'label_printer_port',
|
||||||
|
'label_printer_label_size'
|
||||||
|
];
|
||||||
|
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; });
|
||||||
|
|
||||||
|
document.getElementById('labelPrinterEnabled').checked = vals.label_printer_enabled === 'true';
|
||||||
|
document.getElementById('labelPrinterModel').value = vals.label_printer_model || 'QL-710W';
|
||||||
|
document.getElementById('labelPrinterHost').value = vals.label_printer_host || '172.16.31.32';
|
||||||
|
document.getElementById('labelPrinterPort').value = vals.label_printer_port || '9100';
|
||||||
|
document.getElementById('labelPrinterSize').value = vals.label_printer_label_size || '62';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Label printer settings load failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabelPrinterSettings() {
|
||||||
|
const enabled = document.getElementById('labelPrinterEnabled').checked;
|
||||||
|
const model = (document.getElementById('labelPrinterModel').value || '').trim() || 'QL-710W';
|
||||||
|
const host = (document.getElementById('labelPrinterHost').value || '').trim();
|
||||||
|
const port = (document.getElementById('labelPrinterPort').value || '').trim() || '9100';
|
||||||
|
const size = (document.getElementById('labelPrinterSize').value || '').trim() || '62';
|
||||||
|
const statusEl = document.getElementById('labelPrinterSaveStatus');
|
||||||
|
|
||||||
|
if (enabled && !host) {
|
||||||
|
showNotification('Angiv printer IP/host', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
||||||
|
showNotification('Ugyldig port', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Gemmer...';
|
||||||
|
statusEl.className = 'small text-muted';
|
||||||
|
|
||||||
|
const putSettingStrict = async (key, value) => {
|
||||||
|
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: String(value) })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
putSettingStrict('label_printer_enabled', enabled ? 'true' : 'false'),
|
||||||
|
putSettingStrict('label_printer_model', model),
|
||||||
|
putSettingStrict('label_printer_host', host),
|
||||||
|
putSettingStrict('label_printer_port', String(port)),
|
||||||
|
putSettingStrict('label_printer_label_size', size),
|
||||||
|
]);
|
||||||
|
|
||||||
|
statusEl.textContent = '✅ Gemt';
|
||||||
|
statusEl.className = 'small text-success';
|
||||||
|
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||||
|
showNotification('Label printer indstillinger gemt', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = '❌ Kunne ikke gemme';
|
||||||
|
statusEl.className = 'small text-danger';
|
||||||
|
showNotification('Kunne ikke gemme label printer indstillinger', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadNextcloudInstances() {
|
async function loadNextcloudInstances() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/nextcloud/instances');
|
const response = await fetch('/api/v1/nextcloud/instances');
|
||||||
|
|||||||
@ -220,6 +220,7 @@
|
|||||||
<ul class="dropdown-menu mt-2">
|
<ul class="dropdown-menu mt-2">
|
||||||
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
|
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
|
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Leads</a></li>
|
<li><a class="dropdown-item py-2" href="#">Leads</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
@ -247,6 +248,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="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
||||||
<li><hr class="dropdown-divider"></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>
|
<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>
|
||||||
@ -305,13 +307,18 @@
|
|||||||
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
||||||
<i class="bi bi-plus-circle-fill fs-5"></i>
|
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
|
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
|
||||||
<i class="bi bi-moon-fill"></i>
|
<i class="bi bi-moon-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button>
|
<button class="btn btn-light rounded-circle border-0" id="globalRemindersBtn" style="background: var(--accent-light); color: var(--accent);" title="Åbn reminders">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
</button>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
||||||
@ -406,6 +413,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Results -->
|
||||||
|
<div id="emailResults" class="result-section mb-4" style="display: none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
||||||
|
<i class="bi bi-envelope me-2"></i>Email
|
||||||
|
</h6>
|
||||||
|
<a href="/emails" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-envelope-open me-1"></i>Åbn Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="result-items">
|
||||||
|
<!-- Dynamic results will be inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sales Results -->
|
<!-- Sales Results -->
|
||||||
<div id="salesResults" class="result-section mb-4" style="display: none;">
|
<div id="salesResults" class="result-section mb-4" style="display: none;">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@ -559,8 +581,52 @@
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
||||||
|
const searchBubbleBtn = document.getElementById('globalSearchBtn');
|
||||||
|
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
|
||||||
|
const profileModalEl = document.getElementById('profileModal');
|
||||||
|
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
|
||||||
const globalSearchInput = document.getElementById('globalSearchInput');
|
const globalSearchInput = document.getElementById('globalSearchInput');
|
||||||
|
|
||||||
|
function openGlobalSearchModal() {
|
||||||
|
searchModal.show();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (globalSearchInput) {
|
||||||
|
globalSearchInput.focus();
|
||||||
|
}
|
||||||
|
loadLiveStats();
|
||||||
|
loadRecentActivity();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRemindersModalTab() {
|
||||||
|
if (!profileModalInstance || !profileModalEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profileModalInstance.show();
|
||||||
|
setTimeout(() => {
|
||||||
|
const remindersTabBtn = document.getElementById('profile-reminders-tab');
|
||||||
|
if (remindersTabBtn) {
|
||||||
|
bootstrap.Tab.getOrCreateInstance(remindersTabBtn).show();
|
||||||
|
}
|
||||||
|
loadReminderPreferences();
|
||||||
|
loadProfileReminders();
|
||||||
|
}, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchBubbleBtn) {
|
||||||
|
searchBubbleBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openGlobalSearchModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remindersBubbleBtn) {
|
||||||
|
remindersBubbleBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openRemindersModalTab();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Search input listener with debounce
|
// Search input listener with debounce
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
if (globalSearchInput) {
|
if (globalSearchInput) {
|
||||||
@ -582,6 +648,9 @@
|
|||||||
navigateResults(-1);
|
navigateResults(-1);
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (navigateToSagFromScan(e.target.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectCurrentResult();
|
selectCurrentResult();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -592,15 +661,7 @@
|
|||||||
// Cmd+K / Ctrl+K for global search
|
// Cmd+K / Ctrl+K for global search
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log('Cmd+K pressed - opening search modal'); // Debug
|
openGlobalSearchModal();
|
||||||
searchModal.show();
|
|
||||||
setTimeout(() => {
|
|
||||||
if (globalSearchInput) {
|
|
||||||
globalSearchInput.focus();
|
|
||||||
}
|
|
||||||
loadLiveStats();
|
|
||||||
loadRecentActivity();
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// '+' key for QuickCreate (not in input fields)
|
// '+' key for QuickCreate (not in input fields)
|
||||||
@ -650,6 +711,7 @@
|
|||||||
document.getElementById('workflowActions').style.display = 'none';
|
document.getElementById('workflowActions').style.display = 'none';
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
|
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
});
|
});
|
||||||
@ -810,12 +872,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSagIdFromScanToken(value) {
|
||||||
|
const cleaned = String(value || '').toUpperCase().replace(/\s+/g, ' ').trim();
|
||||||
|
if (!cleaned) return null;
|
||||||
|
|
||||||
|
// Scanner tokens from work order and hardware labels
|
||||||
|
const workOrderMatch = cleaned.match(/\bBMCSCAN-WO-S(\d+)\b/);
|
||||||
|
if (workOrderMatch) return parseInt(workOrderMatch[1], 10);
|
||||||
|
|
||||||
|
const hardwareMatch = cleaned.match(/\bBMCSCAN-HW-(\d+)\b/);
|
||||||
|
if (hardwareMatch) return parseInt(hardwareMatch[1], 10);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToSagFromScan(value) {
|
||||||
|
const sagId = extractSagIdFromScanToken(value);
|
||||||
|
if (!sagId || Number.isNaN(sagId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `/sag/${sagId}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Global search function
|
// Global search function
|
||||||
async function performGlobalSearch(query) {
|
async function performGlobalSearch(query) {
|
||||||
|
if (navigateToSagFromScan(query)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!query || query.trim().length < 2) {
|
if (!query || query.trim().length < 2) {
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
|
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
return;
|
return;
|
||||||
@ -887,6 +978,51 @@
|
|||||||
console.log('Contacts search not available');
|
console.log('Contacts search not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search emails
|
||||||
|
try {
|
||||||
|
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
|
||||||
|
const emailsData = await emailsResponse.json();
|
||||||
|
|
||||||
|
if (Array.isArray(emailsData) && emailsData.length > 0) {
|
||||||
|
hasResults = true;
|
||||||
|
const emailResults = document.getElementById('emailResults');
|
||||||
|
if (emailResults) {
|
||||||
|
emailResults.style.display = 'block';
|
||||||
|
const emailList = emailResults.querySelector('.result-items');
|
||||||
|
if (emailList) {
|
||||||
|
emailList.innerHTML = emailsData.map(mail => {
|
||||||
|
const received = mail.received_date
|
||||||
|
? new Date(mail.received_date).toLocaleString('da-DK')
|
||||||
|
: '-';
|
||||||
|
const sender = mail.sender_name || mail.sender_email || '-';
|
||||||
|
const isUnread = !Boolean(mail.is_read);
|
||||||
|
return `
|
||||||
|
<div class="result-item" onclick="window.location.href='/emails?open=${mail.id}'" style="cursor: pointer;">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">${escapeHtml(mail.subject || '(Ingen emne)')}</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<i class="bi bi-envelope me-1"></i>${escapeHtml(sender)}
|
||||||
|
${mail.linked_case_id ? ` • Sag #${mail.linked_case_id}` : ''}
|
||||||
|
${isUnread ? ' • <span class="text-warning">Ulæst</span>' : ''}
|
||||||
|
• ${escapeHtml(received)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const emailResults = document.getElementById('emailResults');
|
||||||
|
if (emailResults) emailResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Email search not available');
|
||||||
|
const emailResults = document.getElementById('emailResults');
|
||||||
|
if (emailResults) emailResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Search hardware
|
// Search hardware
|
||||||
try {
|
try {
|
||||||
const hardwareResponse = await fetch(`/api/v1/hardware?search=${encodeURIComponent(query)}&limit=5`);
|
const hardwareResponse = await fetch(`/api/v1/hardware?search=${encodeURIComponent(query)}&limit=5`);
|
||||||
@ -1112,8 +1248,43 @@
|
|||||||
|
|
||||||
<div class="tab-content" id="profileTabsContent">
|
<div class="tab-content" id="profileTabsContent">
|
||||||
<div class="tab-pane fade show active" id="profile-overview" role="tabpanel" tabindex="0">
|
<div class="tab-pane fade show active" id="profile-overview" role="tabpanel" tabindex="0">
|
||||||
<div class="alert alert-info small mb-0">
|
<div class="row g-3">
|
||||||
Profilinformation hentes fra din konto. Flere felter kan tilføjes her senere.
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1297,12 +1468,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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const profileModalEl = document.getElementById('profileModal');
|
const profileModalEl = document.getElementById('profileModal');
|
||||||
if (profileModalEl) {
|
if (profileModalEl) {
|
||||||
profileModalEl.addEventListener('shown.bs.modal', () => {
|
profileModalEl.addEventListener('shown.bs.modal', () => {
|
||||||
loadReminderPreferences();
|
loadReminderPreferences();
|
||||||
loadProfileReminders();
|
loadProfileReminders();
|
||||||
|
loadUserProfile();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -95,6 +95,17 @@ class TModuleTimeBase(BaseModel):
|
|||||||
original_hours: Decimal = Field(..., gt=0, description="Original timer")
|
original_hours: Decimal = Field(..., gt=0, description="Original timer")
|
||||||
worked_date: Optional[date] = None
|
worked_date: Optional[date] = None
|
||||||
user_name: Optional[str] = Field(None, max_length=255, description="Bruger")
|
user_name: Optional[str] = Field(None, max_length=255, description="Bruger")
|
||||||
|
start_tid: Optional[datetime] = Field(None, description="Starttid for live timer")
|
||||||
|
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|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)
|
||||||
|
aktiv_timer: Optional[bool] = False
|
||||||
|
round_block_min: Optional[int] = Field(30, ge=1, le=240)
|
||||||
|
ikke_placeret: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class TModuleTimeCreate(TModuleTimeBase):
|
class TModuleTimeCreate(TModuleTimeBase):
|
||||||
@ -110,6 +121,9 @@ class TModuleTimeUpdate(BaseModel):
|
|||||||
billable: Optional[bool] = None
|
billable: Optional[bool] = None
|
||||||
is_travel: Optional[bool] = None
|
is_travel: Optional[bool] = None
|
||||||
status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$")
|
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|anydesk|indedesk|manuel|ukendt)$")
|
||||||
|
|
||||||
|
|
||||||
class TModuleTimeApproval(BaseModel):
|
class TModuleTimeApproval(BaseModel):
|
||||||
|
|||||||
@ -52,6 +52,117 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/timetracking")
|
router = APIRouter(prefix="/timetracking")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_current_user_id(current_user: Optional[dict]) -> Optional[int]:
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
raw = current_user.get("id") or current_user.get("user_id")
|
||||||
|
try:
|
||||||
|
return int(raw) if raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
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
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
normalized = str(value).strip().replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_between(start: Optional[datetime], end: Optional[datetime]) -> Optional[int]:
|
||||||
|
if not start or not end:
|
||||||
|
return None
|
||||||
|
diff_seconds = int((end - start).total_seconds())
|
||||||
|
return max(0, diff_seconds // 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _round_up_minutes(minutes: int, block_minutes: int = 30) -> int:
|
||||||
|
safe_minutes = max(0, int(minutes or 0))
|
||||||
|
safe_block = max(1, int(block_minutes or 30))
|
||||||
|
if safe_minutes == 0:
|
||||||
|
return 0
|
||||||
|
return ((safe_minutes + safe_block - 1) // safe_block) * safe_block
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_status_from_entry_status(entry_status: str) -> str:
|
||||||
|
if entry_status == "godkendt":
|
||||||
|
return "approved"
|
||||||
|
if entry_status == "kladde":
|
||||||
|
return "pending"
|
||||||
|
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
|
# SYNC ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1758,6 +1869,476 @@ async def uninstall_module(
|
|||||||
# INTERNAL / HUB INTEGRATION ENDPOINTS
|
# INTERNAL / HUB INTEGRATION ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/time", tags=["Internal"])
|
||||||
|
async def list_time_entries_v1(
|
||||||
|
sag_id: int = Query(..., gt=0),
|
||||||
|
day: Optional[date] = Query(None),
|
||||||
|
medarbejder_id: Optional[int] = Query(None, gt=0),
|
||||||
|
):
|
||||||
|
"""List tidsregistreringer for en sag med filtre til timeline UI."""
|
||||||
|
try:
|
||||||
|
clauses = ["t.sag_id = %s"]
|
||||||
|
params: List[Any] = [sag_id]
|
||||||
|
|
||||||
|
if day:
|
||||||
|
clauses.append("(t.worked_date = %s OR DATE(t.start_tid) = %s)")
|
||||||
|
params.extend([day, day])
|
||||||
|
|
||||||
|
if medarbejder_id:
|
||||||
|
clauses.append("t.medarbejder_id = %s")
|
||||||
|
params.append(medarbejder_id)
|
||||||
|
|
||||||
|
where_sql = " AND ".join(clauses)
|
||||||
|
query = f"""
|
||||||
|
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(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC
|
||||||
|
"""
|
||||||
|
return execute_query(query, tuple(params))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list time entries")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time/start", tags=["Internal"])
|
||||||
|
async def start_live_timer_v1(
|
||||||
|
payload: Dict[str, Any] = Body(...),
|
||||||
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
|
):
|
||||||
|
"""Start live timer. Kun én aktiv timer pr. bruger; eksisterende auto-pause'es."""
|
||||||
|
try:
|
||||||
|
sag_id = payload.get("sag_id")
|
||||||
|
if not sag_id:
|
||||||
|
raise HTTPException(status_code=400, detail="sag_id is required")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
existing = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, start_tid, round_block_min
|
||||||
|
FROM tmodule_times
|
||||||
|
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
|
||||||
|
ORDER BY start_tid DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(bruger_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
paused_entry = None
|
||||||
|
if existing:
|
||||||
|
actual_minutes = _minutes_between(existing.get("start_tid"), now) or 0
|
||||||
|
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET slut_tid = %s,
|
||||||
|
aktiv_timer = FALSE,
|
||||||
|
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 NULL END,
|
||||||
|
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
|
||||||
|
entry_status = 'afventer',
|
||||||
|
status = 'pending'
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(now, actual_minutes, rounded_minutes, actual_minutes, rounded_minutes, existing.get("round_block_min") or 30, existing["id"])
|
||||||
|
)
|
||||||
|
paused_entry = existing["id"]
|
||||||
|
|
||||||
|
default_user_name = (
|
||||||
|
(current_user or {}).get("username")
|
||||||
|
or (current_user or {}).get("full_name")
|
||||||
|
or "Hub User"
|
||||||
|
)
|
||||||
|
user_name = payload.get("user_name") or default_user_name
|
||||||
|
entry_type = payload.get("entry_type") or "manuel"
|
||||||
|
kilde = payload.get("kilde") or "manuel"
|
||||||
|
billable = bool(payload.get("fakturerbar", True))
|
||||||
|
round_block_min = int(payload.get("round_block_min") or 30)
|
||||||
|
|
||||||
|
created = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO tmodule_times (
|
||||||
|
sag_id, customer_id, description, original_hours,
|
||||||
|
worked_date, user_name, status, billable,
|
||||||
|
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
|
||||||
|
entry_type, kilde, entry_status, medarbejder_id,
|
||||||
|
aktiv_timer, round_block_min, ikke_placeret
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s
|
||||||
|
) RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sag_id,
|
||||||
|
customer_id,
|
||||||
|
payload.get("beskrivelse") or payload.get("description"),
|
||||||
|
0.01,
|
||||||
|
now.date(),
|
||||||
|
user_name,
|
||||||
|
"pending",
|
||||||
|
billable,
|
||||||
|
now,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
entry_type,
|
||||||
|
kilde,
|
||||||
|
"kladde",
|
||||||
|
bruger_id,
|
||||||
|
True,
|
||||||
|
round_block_min,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry": created[0] if created else None,
|
||||||
|
"paused_entry_id": paused_entry,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error starting live timer: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to start timer")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time/stop", tags=["Internal"])
|
||||||
|
async def stop_live_timer_v1(
|
||||||
|
payload: Dict[str, Any] = Body(...),
|
||||||
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
|
):
|
||||||
|
"""Stop aktiv timer for bruger eller specifik entry."""
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
time_id = payload.get("time_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,))
|
||||||
|
else:
|
||||||
|
if not bruger_id:
|
||||||
|
raise HTTPException(status_code=400, detail="medarbejder_id could not be resolved")
|
||||||
|
entry = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM tmodule_times
|
||||||
|
WHERE medarbejder_id = %s AND aktiv_timer = TRUE AND slut_tid IS NULL
|
||||||
|
ORDER BY start_tid DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(bruger_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="No active timer found")
|
||||||
|
|
||||||
|
start_tid = entry.get("start_tid")
|
||||||
|
actual_minutes = payload.get("faktisk_tid_min")
|
||||||
|
if actual_minutes is None:
|
||||||
|
actual_minutes = _minutes_between(start_tid, now)
|
||||||
|
actual_minutes = max(0, int(actual_minutes or 0))
|
||||||
|
|
||||||
|
block_minutes = int(payload.get("round_block_min") or entry.get("round_block_min") or 30)
|
||||||
|
manual_billable = payload.get("fakturerbar_tid_min")
|
||||||
|
billable_minutes = int(manual_billable) if manual_billable is not None else _round_up_minutes(actual_minutes, block_minutes)
|
||||||
|
entry_status = payload.get("entry_status") or "afventer"
|
||||||
|
legacy_status = _legacy_status_from_entry_status(entry_status)
|
||||||
|
|
||||||
|
result = execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET slut_tid = %s,
|
||||||
|
aktiv_timer = FALSE,
|
||||||
|
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 NULL END,
|
||||||
|
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
|
||||||
|
worked_date = COALESCE(worked_date, %s),
|
||||||
|
entry_status = %s,
|
||||||
|
status = %s,
|
||||||
|
ikke_placeret = FALSE
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
now,
|
||||||
|
actual_minutes,
|
||||||
|
billable_minutes,
|
||||||
|
actual_minutes,
|
||||||
|
billable_minutes,
|
||||||
|
block_minutes,
|
||||||
|
now.date(),
|
||||||
|
entry_status,
|
||||||
|
legacy_status,
|
||||||
|
entry["id"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result[0] if result else None
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error stopping live timer: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to stop timer")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time/manual", tags=["Internal"])
|
||||||
|
async def create_manual_time_v1(
|
||||||
|
payload: Dict[str, Any] = Body(...),
|
||||||
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
|
):
|
||||||
|
"""Create manuel tidsregistrering med/uden start-slut."""
|
||||||
|
try:
|
||||||
|
sag_id = payload.get("sag_id")
|
||||||
|
if not sag_id:
|
||||||
|
raise HTTPException(status_code=400, detail="sag_id is required")
|
||||||
|
|
||||||
|
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")
|
||||||
|
or "Hub User"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_tid = _parse_iso_datetime(payload.get("start_tid"))
|
||||||
|
slut_tid = _parse_iso_datetime(payload.get("slut_tid"))
|
||||||
|
actual_minutes = payload.get("faktisk_tid_min")
|
||||||
|
if actual_minutes is None:
|
||||||
|
actual_minutes = _minutes_between(start_tid, slut_tid)
|
||||||
|
if actual_minutes is None:
|
||||||
|
original_hours = float(payload.get("original_hours") or 0)
|
||||||
|
actual_minutes = int(round(original_hours * 60))
|
||||||
|
|
||||||
|
if actual_minutes <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0")
|
||||||
|
|
||||||
|
round_block_min = int(payload.get("round_block_min") or 30)
|
||||||
|
billable = bool(payload.get("fakturerbar", True))
|
||||||
|
billable_minutes = payload.get("fakturerbar_tid_min")
|
||||||
|
if billable_minutes is None:
|
||||||
|
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
|
||||||
|
billable_minutes = int(billable_minutes)
|
||||||
|
|
||||||
|
worked_date = payload.get("worked_date")
|
||||||
|
if not worked_date:
|
||||||
|
if start_tid:
|
||||||
|
worked_date = start_tid.date()
|
||||||
|
elif slut_tid:
|
||||||
|
worked_date = slut_tid.date()
|
||||||
|
|
||||||
|
not_placed = bool(payload.get("ikke_placeret", False)) or (not start_tid and not slut_tid)
|
||||||
|
entry_status = payload.get("entry_status") or "afventer"
|
||||||
|
legacy_status = _legacy_status_from_entry_status(entry_status)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO tmodule_times (
|
||||||
|
sag_id, solution_id, customer_id, description,
|
||||||
|
original_hours, worked_date, user_name,
|
||||||
|
status, billable, billing_method,
|
||||||
|
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
|
||||||
|
entry_type, kilde, entry_status, medarbejder_id,
|
||||||
|
aktiv_timer, round_block_min, ikke_placeret,
|
||||||
|
approved_hours, rounded_to
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, %s
|
||||||
|
) RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
inserted = execute_query(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
sag_id,
|
||||||
|
payload.get("solution_id"),
|
||||||
|
customer_id,
|
||||||
|
payload.get("beskrivelse") or payload.get("description"),
|
||||||
|
max(actual_minutes / 60.0, 0.01),
|
||||||
|
worked_date,
|
||||||
|
payload.get("user_name") or default_user_name,
|
||||||
|
legacy_status,
|
||||||
|
billable,
|
||||||
|
payload.get("billing_method") or ("invoice" if billable else "internal"),
|
||||||
|
start_tid,
|
||||||
|
slut_tid,
|
||||||
|
actual_minutes,
|
||||||
|
billable_minutes,
|
||||||
|
payload.get("entry_type") or "manuel",
|
||||||
|
payload.get("kilde") or "manuel",
|
||||||
|
entry_status,
|
||||||
|
bruger_id,
|
||||||
|
False,
|
||||||
|
round_block_min,
|
||||||
|
not_placed,
|
||||||
|
(billable_minutes / 60.0) if billable else None,
|
||||||
|
(round_block_min / 60.0) if billable else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return inserted[0] if inserted else None
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error creating manual time entry: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create manual entry")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/time/{time_id}", tags=["Internal"])
|
||||||
|
async def patch_time_entry_v1(
|
||||||
|
time_id: int,
|
||||||
|
payload: Dict[str, Any] = Body(...)
|
||||||
|
):
|
||||||
|
"""Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via start/slut."""
|
||||||
|
try:
|
||||||
|
existing = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||||
|
|
||||||
|
updates: Dict[str, Any] = {}
|
||||||
|
allowed_direct = [
|
||||||
|
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
|
||||||
|
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id"
|
||||||
|
]
|
||||||
|
for key in allowed_direct:
|
||||||
|
if key in payload:
|
||||||
|
updates[key] = payload.get(key)
|
||||||
|
|
||||||
|
start_tid = _parse_iso_datetime(payload.get("start_tid")) if "start_tid" in payload else existing.get("start_tid")
|
||||||
|
slut_tid = _parse_iso_datetime(payload.get("slut_tid")) if "slut_tid" in payload else existing.get("slut_tid")
|
||||||
|
if "start_tid" in payload:
|
||||||
|
updates["start_tid"] = start_tid
|
||||||
|
if "slut_tid" in payload:
|
||||||
|
updates["slut_tid"] = slut_tid
|
||||||
|
|
||||||
|
recalculated_minutes = _minutes_between(start_tid, slut_tid)
|
||||||
|
if recalculated_minutes is not None:
|
||||||
|
updates["faktisk_tid_min"] = recalculated_minutes
|
||||||
|
updates["original_hours"] = max(recalculated_minutes / 60.0, 0.01)
|
||||||
|
if "fakturerbar_tid_min" not in updates:
|
||||||
|
block = int(updates.get("round_block_min") or existing.get("round_block_min") or 30)
|
||||||
|
updates["fakturerbar_tid_min"] = _round_up_minutes(recalculated_minutes, block)
|
||||||
|
|
||||||
|
if "entry_status" in updates:
|
||||||
|
updates["status"] = _legacy_status_from_entry_status(updates["entry_status"])
|
||||||
|
|
||||||
|
if "billable" in updates and not updates.get("billable"):
|
||||||
|
updates["fakturerbar_tid_min"] = 0
|
||||||
|
|
||||||
|
if "fakturerbar_tid_min" in updates:
|
||||||
|
billable_minutes = int(updates.get("fakturerbar_tid_min") or 0)
|
||||||
|
updates["approved_hours"] = billable_minutes / 60.0 if (updates.get("billable", existing.get("billable")) and billable_minutes > 0) else 0
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
set_parts = []
|
||||||
|
values: List[Any] = []
|
||||||
|
for field, value in updates.items():
|
||||||
|
set_parts.append(f"{field} = %s")
|
||||||
|
values.append(value)
|
||||||
|
values.append(time_id)
|
||||||
|
|
||||||
|
query = f"UPDATE tmodule_times SET {', '.join(set_parts)} WHERE id = %s RETURNING *"
|
||||||
|
updated = execute_query(query, tuple(values))
|
||||||
|
return updated[0] if updated else None
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error patching time entry %s: %s", time_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to patch time entry")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time/{time_id}/approve", tags=["Internal"])
|
||||||
|
async def approve_time_entry_v1(
|
||||||
|
time_id: int,
|
||||||
|
payload: Dict[str, Any] = Body(default={}),
|
||||||
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
|
):
|
||||||
|
"""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")
|
||||||
|
if billed_minutes is None:
|
||||||
|
billed_minutes = entry.get("fakturerbar_tid_min")
|
||||||
|
if billed_minutes is None:
|
||||||
|
faktisk = int(entry.get("faktisk_tid_min") or 0)
|
||||||
|
billed_minutes = _round_up_minutes(faktisk, int(entry.get("round_block_min") or 30))
|
||||||
|
billed_minutes = int(billed_minutes)
|
||||||
|
|
||||||
|
approved_by = _resolve_current_user_id(current_user)
|
||||||
|
updated = execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET entry_type = %s,
|
||||||
|
entry_status = 'godkendt',
|
||||||
|
status = 'approved',
|
||||||
|
billable = %s,
|
||||||
|
fakturerbar_tid_min = CASE WHEN %s THEN %s ELSE 0 END,
|
||||||
|
approved_hours = CASE WHEN %s THEN (%s::numeric / 60.0) ELSE 0 END,
|
||||||
|
approved_at = %s,
|
||||||
|
approved_by = %s,
|
||||||
|
aktiv_timer = FALSE
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
entry_type,
|
||||||
|
billable,
|
||||||
|
billable,
|
||||||
|
billed_minutes,
|
||||||
|
billable,
|
||||||
|
billed_minutes,
|
||||||
|
datetime.now(),
|
||||||
|
approved_by,
|
||||||
|
time_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return updated[0] if updated else None
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error approving time entry %s: %s", time_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to approve time entry")
|
||||||
|
|
||||||
@router.get("/entries/sag/{sag_id}", tags=["Internal"])
|
@router.get("/entries/sag/{sag_id}", tags=["Internal"])
|
||||||
async def get_time_entries_for_sag(sag_id: int):
|
async def get_time_entries_for_sag(sag_id: int):
|
||||||
"""Get time entries linked to a Hub Sag (Case)."""
|
"""Get time entries linked to a Hub Sag (Case)."""
|
||||||
@ -1790,21 +2371,37 @@ async def create_internal_time_entry(
|
|||||||
description = entry.get("description")
|
description = entry.get("description")
|
||||||
hours = entry.get("original_hours")
|
hours = entry.get("original_hours")
|
||||||
worked_date = entry.get("worked_date") or datetime.now().date()
|
worked_date = entry.get("worked_date") or datetime.now().date()
|
||||||
|
start_tid = _parse_iso_datetime(entry.get("start_tid"))
|
||||||
|
slut_tid = _parse_iso_datetime(entry.get("slut_tid"))
|
||||||
default_user_name = (
|
default_user_name = (
|
||||||
(current_user or {}).get("username")
|
(current_user or {}).get("username")
|
||||||
or (current_user or {}).get("full_name")
|
or (current_user or {}).get("full_name")
|
||||||
or "Hub User"
|
or "Hub User"
|
||||||
)
|
)
|
||||||
user_name = entry.get("user_name") or default_user_name
|
user_name = entry.get("user_name") or default_user_name
|
||||||
|
medarbejder_id = _resolve_current_user_id(current_user) or entry.get("medarbejder_id")
|
||||||
prepaid_card_id = entry.get("prepaid_card_id")
|
prepaid_card_id = entry.get("prepaid_card_id")
|
||||||
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
|
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
|
||||||
work_type = entry.get("work_type", "support")
|
work_type = entry.get("work_type", "support")
|
||||||
is_internal = entry.get("is_internal", False)
|
is_internal = entry.get("is_internal", False)
|
||||||
|
entry_type = entry.get("entry_type", "manuel")
|
||||||
|
kilde = entry.get("kilde", "manuel")
|
||||||
|
entry_status = entry.get("entry_status", "afventer")
|
||||||
|
round_block_min = int(entry.get("round_block_min") or 30)
|
||||||
|
|
||||||
if not sag_id or not hours:
|
if not sag_id or not hours:
|
||||||
raise HTTPException(status_code=400, detail="sag_id and original_hours required")
|
raise HTTPException(status_code=400, detail="sag_id and original_hours required")
|
||||||
|
|
||||||
hours_decimal = float(hours)
|
hours_decimal = float(hours)
|
||||||
|
actual_minutes = entry.get("faktisk_tid_min")
|
||||||
|
if actual_minutes is None:
|
||||||
|
actual_minutes = _minutes_between(start_tid, slut_tid)
|
||||||
|
if actual_minutes is None:
|
||||||
|
actual_minutes = int(round(hours_decimal * 60))
|
||||||
|
|
||||||
|
billable_minutes = entry.get("fakturerbar_tid_min")
|
||||||
|
if billable_minutes is None:
|
||||||
|
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
|
||||||
|
|
||||||
# Auto-resolve customer if missing
|
# Auto-resolve customer if missing
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
@ -1985,12 +2582,18 @@ async def create_internal_time_entry(
|
|||||||
sag_id, solution_id, customer_id, description,
|
sag_id, solution_id, customer_id, description,
|
||||||
original_hours, worked_date, user_name,
|
original_hours, worked_date, user_name,
|
||||||
status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
|
status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
|
||||||
approved_hours, rounded_to
|
approved_hours, rounded_to,
|
||||||
|
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
|
||||||
|
entry_type, kilde, entry_status, medarbejder_id,
|
||||||
|
aktiv_timer, round_block_min, ikke_placeret
|
||||||
) VALUES (
|
) VALUES (
|
||||||
%s, %s, %s, %s,
|
%s, %s, %s, %s,
|
||||||
%s, %s, %s,
|
%s, %s, %s,
|
||||||
%s, %s, %s, %s, %s, %s,
|
%s, %s, %s, %s, %s, %s,
|
||||||
%s, %s
|
%s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s
|
||||||
) RETURNING *
|
) RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -1998,7 +2601,10 @@ async def create_internal_time_entry(
|
|||||||
sag_id, solution_id, customer_id, description,
|
sag_id, solution_id, customer_id, description,
|
||||||
hours, worked_date, user_name,
|
hours, worked_date, user_name,
|
||||||
status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
|
status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
|
||||||
entry.get('approved_hours'), entry.get('rounded_to')
|
entry.get('approved_hours'), entry.get('rounded_to'),
|
||||||
|
start_tid, slut_tid, actual_minutes, billable_minutes,
|
||||||
|
entry_type, kilde, entry_status, medarbejder_id,
|
||||||
|
False, round_block_min, bool(entry.get("ikke_placeret", False) or (not start_tid and not slut_tid))
|
||||||
)
|
)
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
if result:
|
if result:
|
||||||
|
|||||||
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()
|
||||||
20
check_threads.sql
Normal file
20
check_threads.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- Check thread fragmentation per SAG
|
||||||
|
WITH resolved AS (
|
||||||
|
SELECT
|
||||||
|
se.sag_id,
|
||||||
|
em.id,
|
||||||
|
em.thread_key,
|
||||||
|
em.folder,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(em.thread_key, '')), '[<>\s]', '', 'g'), ''),
|
||||||
|
CONCAT('email-', em.id::text)
|
||||||
|
) AS resolved_key
|
||||||
|
FROM sag_emails se
|
||||||
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
|
WHERE em.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT sag_id, COUNT(DISTINCT resolved_key) as thread_count, COUNT(*) as email_count
|
||||||
|
FROM resolved
|
||||||
|
GROUP BY sag_id
|
||||||
|
HAVING COUNT(DISTINCT resolved_key) > 1
|
||||||
|
ORDER BY thread_count DESC;
|
||||||
@ -50,7 +50,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Override database URL to point to postgres service
|
# Override database URL to point to postgres service
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
- ENABLE_RELOAD=false
|
- ENABLE_RELOAD=${ENABLE_RELOAD:-true}
|
||||||
- APIGW_TOKEN=${APIGW_TOKEN}
|
- APIGW_TOKEN=${APIGW_TOKEN}
|
||||||
- APIGATEWAY_URL=${APIGATEWAY_URL}
|
- APIGATEWAY_URL=${APIGATEWAY_URL}
|
||||||
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
|
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
|
||||||
|
|||||||
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 router as devportal_api
|
||||||
from app.devportal.backend import views as devportal_views
|
from app.devportal.backend import views as devportal_views
|
||||||
from app.routers import anydesk
|
from app.routers import anydesk
|
||||||
|
from app.anydesk.backend import views as anydesk_views
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
from app.modules.webshop.backend import router as webshop_api
|
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)
|
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")
|
logger.info("✅ System initialized successfully")
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# 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(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
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
|
# Frontend Routers
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
app.include_router(dashboard_views.router, tags=["Frontend"])
|
||||||
app.include_router(customers_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(telefoni_views.router, tags=["Frontend"])
|
||||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||||
app.include_router(orders_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)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
110
migrations/150_sag_tidsforbrug_v1.sql
Normal file
110
migrations/150_sag_tidsforbrug_v1.sql
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
-- Migration 150: Sag tidsforbrug v1 foundation
|
||||||
|
-- Formål: Udvide tmodule_times med felter til live timer, faktisk/fakturerbar minutter,
|
||||||
|
-- status-flow og type/kilde uden at bryde eksisterende timetracking-flow.
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD COLUMN IF NOT EXISTS start_tid TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS slut_tid TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS faktisk_tid_min INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS fakturerbar_tid_min INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS entry_type VARCHAR(32) DEFAULT 'ukendt',
|
||||||
|
ADD COLUMN IF NOT EXISTS kilde VARCHAR(32) DEFAULT 'manuel',
|
||||||
|
ADD COLUMN IF NOT EXISTS entry_status VARCHAR(32) DEFAULT 'afventer',
|
||||||
|
ADD COLUMN IF NOT EXISTS medarbejder_id INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS aktiv_timer BOOLEAN DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS round_block_min INTEGER DEFAULT 30,
|
||||||
|
ADD COLUMN IF NOT EXISTS ikke_placeret BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Optional settings per customer/type (fx mail default minutter)
|
||||||
|
CREATE TABLE IF NOT EXISTS tmodule_time_defaults (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER REFERENCES tmodule_customers(id) ON DELETE CASCADE,
|
||||||
|
entry_type VARCHAR(32) NOT NULL,
|
||||||
|
default_minutes INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT tmodule_time_defaults_default_minutes_positive CHECK (default_minutes > 0),
|
||||||
|
CONSTRAINT tmodule_time_defaults_unique UNIQUE (customer_id, entry_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Backfill for existing rows so gamle data stadig virker i ny UI/API.
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET
|
||||||
|
entry_type = COALESCE(entry_type, 'ukendt'),
|
||||||
|
kilde = COALESCE(kilde, 'api'),
|
||||||
|
entry_status = COALESCE(
|
||||||
|
entry_status,
|
||||||
|
CASE
|
||||||
|
WHEN status = 'approved' THEN 'godkendt'
|
||||||
|
WHEN status = 'pending' THEN 'afventer'
|
||||||
|
WHEN status = 'billed' THEN 'godkendt'
|
||||||
|
ELSE 'kladde'
|
||||||
|
END
|
||||||
|
),
|
||||||
|
faktisk_tid_min = COALESCE(faktisk_tid_min, CEIL(COALESCE(original_hours, 0)::numeric * 60)::int),
|
||||||
|
fakturerbar_tid_min = COALESCE(
|
||||||
|
fakturerbar_tid_min,
|
||||||
|
CEIL(COALESCE(approved_hours, original_hours, 0)::numeric * 60)::int
|
||||||
|
),
|
||||||
|
round_block_min = COALESCE(round_block_min, 30),
|
||||||
|
aktiv_timer = COALESCE(aktiv_timer, FALSE),
|
||||||
|
ikke_placeret = COALESCE(ikke_placeret, FALSE);
|
||||||
|
|
||||||
|
-- Guards
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'tmodule_times_faktisk_tid_min_positive'
|
||||||
|
AND conrelid = 'tmodule_times'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD CONSTRAINT tmodule_times_faktisk_tid_min_positive CHECK (faktisk_tid_min IS NULL OR faktisk_tid_min >= 0);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'tmodule_times_fakturerbar_tid_min_positive'
|
||||||
|
AND conrelid = 'tmodule_times'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD CONSTRAINT tmodule_times_fakturerbar_tid_min_positive CHECK (fakturerbar_tid_min IS NULL OR fakturerbar_tid_min >= 0);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'tmodule_times_entry_status_check'
|
||||||
|
AND conrelid = 'tmodule_times'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD CONSTRAINT tmodule_times_entry_status_check CHECK (entry_status IN ('kladde', 'afventer', 'godkendt'));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'tmodule_times_entry_type_check'
|
||||||
|
AND conrelid = 'tmodule_times'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD CONSTRAINT tmodule_times_entry_type_check CHECK (entry_type IN ('opkald', 'mail', 'indedesk', 'manuel', 'ukendt'));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'tmodule_times_kilde_check'
|
||||||
|
AND conrelid = 'tmodule_times'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD CONSTRAINT tmodule_times_kilde_check CHECK (kilde IN ('auto', 'manuel', 'api'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_start_tid ON tmodule_times(start_tid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_medarbejder_id ON tmodule_times(medarbejder_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_entry_status ON tmodule_times(entry_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_aktiv_timer ON tmodule_times(aktiv_timer);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_time_defaults_customer ON tmodule_time_defaults(customer_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_tmodule_times_active_timer_per_user
|
||||||
|
ON tmodule_times(medarbejder_id)
|
||||||
|
WHERE aktiv_timer = TRUE AND slut_tid IS NULL;
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
-- Migration 151: Reconcile opportunity comment attachments schema with migration 019 expectations
|
||||||
|
-- Some environments already had pipeline_opportunity_comment_attachments with legacy columns,
|
||||||
|
-- so migration 019 (CREATE TABLE IF NOT EXISTS) did not add these fields.
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS pipeline_opportunity_comment_attachments
|
||||||
|
ADD COLUMN IF NOT EXISTS content_type VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS stored_name TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by_user_id INTEGER;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'pipeline_opportunity_comment_attachments'
|
||||||
|
AND column_name = 'file_path'
|
||||||
|
) THEN
|
||||||
|
UPDATE pipeline_opportunity_comment_attachments
|
||||||
|
SET stored_name = COALESCE(stored_name, file_path)
|
||||||
|
WHERE stored_name IS NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'pipeline_opportunity_comment_attachments'
|
||||||
|
AND column_name = 'file_type'
|
||||||
|
) THEN
|
||||||
|
UPDATE pipeline_opportunity_comment_attachments
|
||||||
|
SET content_type = COALESCE(content_type, file_type)
|
||||||
|
WHERE content_type IS NULL;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'pipeline_opportunity_comment_attachments_uploaded_by_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE pipeline_opportunity_comment_attachments
|
||||||
|
ADD CONSTRAINT pipeline_opportunity_comment_attachments_uploaded_by_fkey
|
||||||
|
FOREIGN KEY (uploaded_by_user_id)
|
||||||
|
REFERENCES users(user_id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Keep existing rows valid while allowing future inserts to set explicit stored_name.
|
||||||
|
UPDATE pipeline_opportunity_comment_attachments
|
||||||
|
SET stored_name = COALESCE(stored_name, filename)
|
||||||
|
WHERE stored_name IS NULL;
|
||||||
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;
|
||||||
48
migrations/156_backfill_email_thread_keys.sql
Normal file
48
migrations/156_backfill_email_thread_keys.sql
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
-- Migration 156: Backfill email thread_keys from parent emails
|
||||||
|
-- Ensures replies inherit the same thread_key as their parent so they group together visually.
|
||||||
|
|
||||||
|
-- Step 1: For emails that have in_reply_to or email_references pointing to an existing
|
||||||
|
-- email with a thread_key, adopt the parent's thread_key.
|
||||||
|
UPDATE email_messages child
|
||||||
|
SET thread_key = parent.thread_key,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
FROM email_messages parent
|
||||||
|
WHERE child.deleted_at IS NULL
|
||||||
|
AND parent.deleted_at IS NULL
|
||||||
|
AND parent.thread_key IS NOT NULL
|
||||||
|
AND TRIM(parent.thread_key) != ''
|
||||||
|
AND (
|
||||||
|
-- Match via in_reply_to -> parent message_id
|
||||||
|
(
|
||||||
|
child.in_reply_to IS NOT NULL
|
||||||
|
AND TRIM(child.in_reply_to) != ''
|
||||||
|
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
|
||||||
|
= LOWER(REGEXP_REPLACE(
|
||||||
|
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.in_reply_to), E'[\\s,]+'))[1],
|
||||||
|
'[<>\s]', '', 'g'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
-- Match via first reference -> parent message_id
|
||||||
|
(
|
||||||
|
child.email_references IS NOT NULL
|
||||||
|
AND TRIM(child.email_references) != ''
|
||||||
|
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
|
||||||
|
= LOWER(REGEXP_REPLACE(
|
||||||
|
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.email_references), E'[\\s,]+'))[1],
|
||||||
|
'[<>\s]', '', 'g'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- Only update if the thread_key would actually change
|
||||||
|
AND (
|
||||||
|
child.thread_key IS NULL
|
||||||
|
OR TRIM(child.thread_key) = ''
|
||||||
|
OR LOWER(REGEXP_REPLACE(child.thread_key, '[<>\s]', '', 'g'))
|
||||||
|
!= LOWER(REGEXP_REPLACE(parent.thread_key, '[<>\s]', '', 'g'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 2: REMOVED - was incorrectly forcing all emails in a SAG to share one thread_key.
|
||||||
|
-- Each SAG can have multiple independent email threads (different recipients/subjects).
|
||||||
|
-- Thread grouping is based on actual RFC 5322 threading headers, not SAG membership.
|
||||||
|
-- See migration 157 for the fix.
|
||||||
57
migrations/157_fix_thread_keys_multi_thread.sql
Normal file
57
migrations/157_fix_thread_keys_multi_thread.sql
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
-- Migration 157: Fix thread_keys - restore correct per-conversation grouping
|
||||||
|
-- Migration 156 Step 2 incorrectly forced ALL emails in a SAG to share one thread_key.
|
||||||
|
-- This migration restores the correct thread_key based on actual email conversation headers.
|
||||||
|
|
||||||
|
-- Step 1: Restore thread_key for emails that have a Graph conversationId stored
|
||||||
|
-- (these were overwritten by the dominant-thread backfill).
|
||||||
|
-- The conversationId is the most reliable conversation identifier from Exchange/Graph.
|
||||||
|
|
||||||
|
-- Step 2: Re-derive thread_keys from actual email headers.
|
||||||
|
-- Priority: conversationId (if provider) > parent's thread_key > References[0] > In-Reply-To > message_id
|
||||||
|
-- We re-derive for ALL emails to undo the forced unification.
|
||||||
|
|
||||||
|
-- First, recalculate based on actual References/In-Reply-To parent chain.
|
||||||
|
-- For emails that are replies (have in_reply_to or email_references), adopt the
|
||||||
|
-- thread_key of the ACTUAL parent email (matched by message_id), not just any email in the SAG.
|
||||||
|
UPDATE email_messages child
|
||||||
|
SET thread_key = parent.thread_key,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
FROM email_messages parent
|
||||||
|
WHERE child.deleted_at IS NULL
|
||||||
|
AND parent.deleted_at IS NULL
|
||||||
|
AND parent.thread_key IS NOT NULL
|
||||||
|
AND TRIM(parent.thread_key) != ''
|
||||||
|
AND (
|
||||||
|
-- Match via in_reply_to -> parent message_id
|
||||||
|
(
|
||||||
|
child.in_reply_to IS NOT NULL
|
||||||
|
AND TRIM(child.in_reply_to) != ''
|
||||||
|
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
|
||||||
|
= LOWER(REGEXP_REPLACE(
|
||||||
|
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.in_reply_to), E'[\\s,]+'))[1],
|
||||||
|
'[<>\s]', '', 'g'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
-- Match via first reference -> parent message_id
|
||||||
|
(
|
||||||
|
child.email_references IS NOT NULL
|
||||||
|
AND TRIM(child.email_references) != ''
|
||||||
|
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
|
||||||
|
= LOWER(REGEXP_REPLACE(
|
||||||
|
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.email_references), E'[\\s,]+'))[1],
|
||||||
|
'[<>\s]', '', 'g'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- For emails that are conversation starters (no in_reply_to, no references),
|
||||||
|
-- reset thread_key to their own message_id so they start their own thread.
|
||||||
|
UPDATE email_messages
|
||||||
|
SET thread_key = LOWER(REGEXP_REPLACE(COALESCE(message_id, ''), '[<>\s]', '', 'g')),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (in_reply_to IS NULL OR TRIM(in_reply_to) = '')
|
||||||
|
AND (email_references IS NULL OR TRIM(email_references) = '')
|
||||||
|
AND message_id IS NOT NULL
|
||||||
|
AND TRIM(message_id) != '';
|
||||||
32
migrations/158_sag_work_orders_and_scan_tokens.sql
Normal file
32
migrations/158_sag_work_orders_and_scan_tokens.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- Migration 158: SAG work-order scan tokens and file provenance
|
||||||
|
-- Enables token-based auto-linking of scanned documents to cases.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_document_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
token_type VARCHAR(40) NOT NULL,
|
||||||
|
hardware_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
|
||||||
|
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
consumed_at TIMESTAMP,
|
||||||
|
consumed_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT sag_document_tokens_type_check CHECK (token_type IN ('work_order', 'hardware_label'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_sag_id ON sag_document_tokens(sag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_token_type ON sag_document_tokens(token_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_consumed ON sag_document_tokens(consumed_at);
|
||||||
|
|
||||||
|
ALTER TABLE sag_files
|
||||||
|
ADD COLUMN IF NOT EXISTS source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS source_type VARCHAR(40),
|
||||||
|
ADD COLUMN IF NOT EXISTS source_token VARCHAR(120);
|
||||||
|
|
||||||
|
UPDATE sag_files
|
||||||
|
SET source_type = 'upload'
|
||||||
|
WHERE source_type IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_files_source_email_id ON sag_files(source_email_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_files_source_token ON sag_files(source_token);
|
||||||
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
|
import re
|
||||||
|
|
||||||
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
with open('app/modules/sag/templates/detail.html', 'r') as f:
|
||||||
content = f.read()
|
text = f.read()
|
||||||
|
|
||||||
def extract_between(text, start_marker, end_marker):
|
# Let's verify the file content is around 6600 lines
|
||||||
start = text.find(start_marker)
|
print(f"Total lines: {len(text.splitlines())}")
|
||||||
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
|
|
||||||
|
|
||||||
def extract_div_by_marker(text, marker):
|
# Search for the function renderTimeV1Timeline
|
||||||
start = text.find(marker)
|
match = re.search(r'function renderTimeV1Timeline\(entries\)\s*{.*?timeline\.innerHTML = Object\.entries\(grouped\).*?\}', text, re.DOTALL)
|
||||||
if start == -1: return "", text
|
if match:
|
||||||
|
print(f"Found render function, length: {len(match.group(0))}")
|
||||||
# find the open div tag nearest to the marker looking backwards
|
else:
|
||||||
div_start = text.rfind('<div', 0, start)
|
print(f"Could not find render function")
|
||||||
# wait, sometimes marker is inside the div or before the div.
|
match = re.search(r'function renderTimeV1Timeline\(entries\)\s*{', text)
|
||||||
pass
|
if match:
|
||||||
|
print("Found definition at index:", match.start())
|
||||||
print("Content loaded, len:", len(content))
|
|
||||||
|
|||||||
@ -1,66 +1,22 @@
|
|||||||
import sys
|
def replace_chunk(text, start_str, end_str, new_content):
|
||||||
import re
|
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):
|
with open('app/modules/sag/templates/detail.html', 'r') as f:
|
||||||
i = start_idx
|
text = f.read()
|
||||||
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)
|
|
||||||
|
|
||||||
if next_open == -1 and next_close == -1:
|
# Test finding CSS
|
||||||
break
|
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):
|
# Test finding JS
|
||||||
tag_count += 1
|
start_js = " function renderTimeV1Timeline(entries) {"
|
||||||
i = next_open + 4
|
end_js = " </div>\n `;\n }).join('');\n }"
|
||||||
else:
|
if start_js in text and end_js in text:
|
||||||
tag_count -= 1
|
print("Found JS block")
|
||||||
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)}")
|
|
||||||
|
|||||||
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])
|
||||||
@ -20,3 +20,6 @@ APScheduler==3.10.4
|
|||||||
pdfplumber==0.11.4
|
pdfplumber==0.11.4
|
||||||
av==13.1.0
|
av==13.1.0
|
||||||
Pillow==11.0.0
|
Pillow==11.0.0
|
||||||
|
brother_ql==0.9.4
|
||||||
|
pyzbar==0.1.9
|
||||||
|
pypdfium2==4.30.0
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user