Merge branch 'feature/sag-tidsforbrug-v1'

This commit is contained in:
Christian 2026-04-01 21:36:23 +02:00
commit ba601e38b1
110 changed files with 23348 additions and 672 deletions

View File

@ -16,6 +16,11 @@ API_HOST=0.0.0.0
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=
FIRMAAPI_TIMEOUT_SECONDS=12
# =====================================================
# 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())"
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)
# =====================================================

View File

@ -44,6 +44,11 @@ API_HOST=0.0.0.0
API_PORT=8000
API_RELOAD=false
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=
FIRMAAPI_TIMEOUT_SECONDS=12
# =====================================================
# 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
ECONOMIC_READ_ONLY=true
ECONOMIC_DRY_RUN=true
# =====================================================
# Links / Endpoints Module - Production (Optional)
# =====================================================
# Start disabled; enable after migration + validation
LINKS_MODULE_ENABLED=false
LINKS_READ_ONLY=true
LINKS_DRY_RUN=true
LINKS_DEAD_LINK_CHECK_ENABLED=true
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
LINKS_CHECK_TIMEOUT_SECONDS=5
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL=
VAULTWARDEN_API_TOKEN=

View File

@ -107,6 +107,23 @@ if settings.ECONOMIC_READ_ONLY:
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
```bash

View File

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

142
add_css.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -4,6 +4,53 @@
{% block extra_css %}
<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 {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
@ -21,6 +68,139 @@
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 {
width: 40px;
height: 40px;
@ -32,6 +212,7 @@
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
box-shadow: inset 0 0 0 1px rgba(15, 76, 117, 0.12);
}
.pagination-btn {
@ -52,21 +233,136 @@
opacity: 0.5;
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>
{% endblock %}
{% 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>
<h2 class="fw-bold mb-1">Kontakter</h2>
<p class="text-muted mb-0">Administrer kontaktpersoner</p>
</div>
<div class="d-flex gap-3">
<input type="text" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon...">
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button>
<div class="toolbar-search-slot">
<div class="search-wrap">
<input type="search" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon eller firma..." 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>
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap">
@ -81,9 +377,9 @@
</button>
</div>
<div class="card p-4">
<div class="table-responsive">
<table class="table table-hover align-middle">
<div class="card p-4 contacts-shell">
<div class="table-responsive contacts-table-wrap">
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
@ -123,7 +419,7 @@
</div>
<!-- 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-content">
<div class="modal-header">
@ -175,10 +471,19 @@
<div class="col-12">
<label class="form-label">Firmaer</label>
<select class="form-select" id="companySelect" multiple size="5">
<!-- Populated dynamically -->
</select>
<div class="form-text">Hold Ctrl/Cmd nede for at vælge flere firmaer</div>
<div class="company-picker">
<input
type="search"
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 class="col-12">
@ -292,22 +597,72 @@ let pageSize = 20;
let currentFilter = 'all';
let searchQuery = '';
let totalContacts = 0;
let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let availableCompanies = [];
let selectedCompanyIds = new Set();
// Load contacts on page load
document.addEventListener('DOMContentLoaded', () => {
loadContacts();
loadCompaniesForSelect();
// Search with debounce
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn');
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);
toggleClearButton(e.target.value.trim());
searchTimeout = setTimeout(() => {
searchQuery = e.target.value;
currentPage = 0;
loadContacts();
triggerSearch();
}, 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) {
@ -327,6 +682,11 @@ async function loadContacts() {
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>';
if (currentRequestController) {
currentRequestController.abort();
}
currentRequestController = new AbortController();
try {
// Build query parameters
let params = new URLSearchParams({
@ -344,7 +704,13 @@ async function loadContacts() {
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();
totalContacts = data.total;
@ -352,11 +718,20 @@ async function loadContacts() {
updatePagination(data.total);
} catch (error) {
if (error.name === 'AbortError') {
return;
}
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>';
} finally {
currentRequestController = null;
}
}
function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
}
function displayContacts(contacts) {
const tbody = document.getElementById('contactsTableBody');
@ -368,8 +743,8 @@ function displayContacts(contacts) {
tbody.innerHTML = contacts.map(contact => {
const initials = getInitials(contact.first_name, contact.last_name);
const statusBadge = contact.is_active
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>'
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
? '<span class="status-pill active">Aktiv</span>'
: '<span class="status-pill inactive">Inaktiv</span>';
const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || [];
@ -389,36 +764,41 @@ function displayContacts(contacts) {
</div>`
: '';
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 `
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})">
<tr onclick="viewContact(${contact.id})">
<td>
<div class="d-flex align-items-center">
<div class="contact-avatar me-3">${initials}</div>
<div>
<div class="fw-bold">${escapeHtml(contact.first_name + ' ' + contact.last_name)}</div>
<div class="small text-muted">${contact.department || '-'}</div>
<div class="contact-name">${safeName}</div>
<div class="contact-subline">${safeDepartment}</div>
</div>
</div>
</td>
<td>
<div class="fw-medium">${contact.email || '-'}</div>
${smsLine}
<div class="contact-info-main">${safeEmail}</div>
<div class="contact-quick-actions">${smsLine}</div>
</td>
<td class="text-muted">${contact.title || '-'}</td>
<td class="text-muted">${safeTitle}</td>
<td>
<span class="badge bg-light text-dark border" title="${companyNames.join(', ')}">
<i class="bi bi-building me-1"></i>${companyCount}
<span class="company-count-chip" title="${companiesTitle}">
<i class="bi bi-building"></i>${companyCount}
</span>
${companyDisplay !== '-' ? '<div class="small text-muted">' + companyDisplay + '</div>' : ''}
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
</td>
<td>${statusBadge}</td>
<td class="text-end">
<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>
</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>
</button>
</div>
@ -581,19 +961,87 @@ async function loadCompaniesForSelect() {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
const select = document.getElementById('companySelect');
select.innerHTML = data.customers.map(c =>
`<option value="${c.id}">${escapeHtml(c.name)}</option>`
).join('');
availableCompanies = Array.isArray(data.customers)
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
: [];
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
renderSelectedCompanies();
} catch (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() {
// Reset form
document.getElementById('createContactForm').reset();
document.getElementById('isActiveInput').checked = true;
selectedCompanyIds = new Set();
const companySearchInput = document.getElementById('companySearchInput');
if (companySearchInput) {
companySearchInput.value = '';
}
renderCompanyResults('');
renderSelectedCompanies();
// Show modal
const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
@ -610,8 +1058,7 @@ async function createContact() {
}
// Get selected company IDs
const companySelect = document.getElementById('companySelect');
const companyIds = Array.from(companySelect.selectedOptions).map(opt => parseInt(opt.value));
const companyIds = Array.from(selectedCompanyIds);
const contactData = {
first_name: firstName,

View File

@ -31,6 +31,11 @@ class Settings(BaseSettings):
APIGW_TOKEN: str = ""
APIGW_TIMEOUT_SECONDS: int = 12
# FirmaAPI (CVR company data)
FIRMAAPI_BASE_URL: str = "https://firmaapi.dk/api/v1"
FIRMAAPI_API_KEY: str = ""
FIRMAAPI_TIMEOUT_SECONDS: int = 12
# Security
SECRET_KEY: str = "dev-secret-key-change-in-production"
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
@ -70,6 +75,18 @@ class Settings(BaseSettings):
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
NEXTCLOUD_ENCRYPTION_KEY: str = ""
# Links / Endpoints Module
LINKS_MODULE_ENABLED: bool = False
LINKS_READ_ONLY: bool = True
LINKS_DRY_RUN: bool = True
LINKS_DEAD_LINK_CHECK_ENABLED: bool = True
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60
LINKS_CHECK_TIMEOUT_SECONDS: int = 5
# Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL: str = ""
VAULTWARDEN_API_TOKEN: str = ""
# Wiki.js Integration
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
WIKI_API_TOKEN: str = ""
@ -227,9 +244,10 @@ class Settings(BaseSettings):
REMINDERS_QUEUE_BATCH_SIZE: int = 10
# AnyDesk Remote Support Integration
ANYDESK_API_URL: str = "https://v1.api.anydesk.com:8081" # AnyDesk REST API base URL
ANYDESK_LICENSE_ID: str = ""
ANYDESK_API_TOKEN: str = ""
ANYDESK_PASSWORD: str = ""
ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com
ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
ANYDESK_TIMEOUT_SECONDS: int = 30

View File

@ -12,7 +12,7 @@ import asyncio
import aiohttp
from urllib.parse import quote
from app.core.database import execute_query, execute_query_single, execute_update
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
from app.core.config import settings
from app.services.cvr_service import get_cvr_service
from app.services.customer_activity_logger import CustomerActivityLogger
@ -81,7 +81,8 @@ async def list_customers(
offset: int = Query(default=0, ge=0),
search: Optional[str] = Query(default=None),
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
is_active: Optional[bool] = Query(default=None)
is_active: Optional[bool] = Query(default=None),
vip: Optional[bool] = Query(default=None)
):
"""
List customers with pagination and filtering
@ -138,6 +139,19 @@ async def list_customers(
query += " AND c.is_active = %s"
params.append(is_active)
# Add VIP filter (customer tagged with "vip")
if vip is True:
query += """
AND EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'customer'
AND et.entity_id = c.id
AND LOWER(t.name) = 'vip'
)
"""
query += """
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
ORDER BY c.name
@ -170,6 +184,18 @@ async def list_customers(
count_query += " AND is_active = %s"
count_params.append(is_active)
if vip is True:
count_query += """
AND EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'customer'
AND et.entity_id = customers.id
AND LOWER(t.name) = 'vip'
)
"""
count_result = execute_query_single(count_query, tuple(count_params))
total = count_result['total'] if count_result else 0

View File

@ -245,6 +245,9 @@
</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-light btn-sm" href="/links?customer_id={{ customer_id }}" title="Se links/endpoints for denne kunde">
<i class="bi bi-link-45deg me-2"></i>Links
</a>
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
</button>
@ -309,6 +312,11 @@
<i class="bi bi-people"></i>Kontakter
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#cases">
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt
@ -344,6 +352,11 @@
<i class="bi bi-hdd"></i>Hardware
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#links">
<i class="bi bi-link-45deg"></i>Links
</a>
</li>
<li class="nav-item d-none" id="nextcloudTabNav">
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
<i class="bi bi-cloud"></i>Nextcloud
@ -519,6 +532,48 @@
</div>
</div>
<!-- Cases Tab -->
<div class="tab-pane fade" id="cases">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kundens sager</h5>
<small class="text-muted">Alle sager knyttet til denne kunde</small>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-primary" href="/sag/new?customer_id={{ customer_id }}">
<i class="bi bi-plus-lg me-2"></i>Opret sag
</a>
<a class="btn btn-sm btn-outline-secondary" href="/sag?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn i sagsmodul
</a>
</div>
</div>
<div class="table-responsive" id="customerCasesContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerCasesEmpty" class="text-center py-5 text-muted d-none">
Ingen sager fundet for denne kunde
</div>
</div>
<!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4">
@ -748,6 +803,42 @@
</div>
</div>
<!-- Links Tab -->
<div class="tab-pane fade" id="links">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Links / Endpoints</h5>
<small class="text-muted">Driftslinks knyttet til denne kunde</small>
</div>
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn fuld visning
</a>
</div>
<div class="table-responsive" id="customerLinksContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerLinksEmpty" class="text-center py-5 text-muted d-none">
Ingen links fundet for denne kunde
</div>
</div>
<!-- Nextcloud Tab -->
<div class="tab-pane fade d-none" id="nextcloud">
{% include "modules/nextcloud/templates/tab.html" %}
@ -1210,6 +1301,11 @@ let customerKontaktFilter = 'all';
let eventListenersAdded = false;
function getAuthHeaders() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
document.addEventListener('DOMContentLoaded', () => {
if (eventListenersAdded) {
console.log('Event listeners already added, skipping...');
@ -1226,6 +1322,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const casesTab = document.querySelector('a[href="#cases"]');
if (casesTab) {
casesTab.addEventListener('shown.bs.tab', () => {
loadCustomerCases();
}, { once: false });
}
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
@ -1266,6 +1369,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const linksTab = document.querySelector('a[href="#links"]');
if (linksTab) {
linksTab.addEventListener('shown.bs.tab', () => {
loadCustomerLinks();
}, { once: false });
}
// Load activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]');
if (activityTab) {
@ -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;
async function loadSubscriptions() {
@ -2376,6 +2587,7 @@ async function loadCustomerPipeline() {
let customerHardware = [];
let hardwareLocationsById = {};
let customerLinks = [];
function getHardwareGroupLabel(item, groupBy) {
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) {
const tbody = document.getElementById('customerOpportunitiesTable');
if (!opportunities || opportunities.length === 0) {

View File

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

View File

@ -107,6 +107,21 @@ class MissionService:
FROM contacts c
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)
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
"""
row = execute_query_single(query, (caller_number, caller_number))
@ -282,7 +297,7 @@ class MissionService:
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
ORDER BY
CASE LOWER(COALESCE(s.priority, ''))
CASE LOWER(COALESCE(s.priority::text, ''))
WHEN 'kritisk' THEN 5
WHEN 'critical' THEN 5
WHEN 'høj' THEN 4

View File

@ -934,17 +934,43 @@
if (!AudioCtx) return;
const context = new AudioCtx();
const oscillator = context.createOscillator();
const gainNode = context.createGain();
const now = context.currentTime;
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';
oscillator.frequency.value = type === 'uptime_down' ? 260 : 620;
gainNode.gain.value = gainValue * 0.2;
function strike(startAt, ampScale) {
const fundamental = context.createOscillator();
const overtone = context.createOscillator();
const gainNode = context.createGain();
oscillator.connect(gainNode);
gainNode.connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + (type === 'uptime_down' ? 0.35 : 0.15));
fundamental.type = 'sine';
overtone.type = 'triangle';
fundamental.frequency.setValueAtTime(baseFreq, startAt);
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) {

View File

@ -4,7 +4,7 @@ API endpoints for email viewing, classification, and rule management
"""
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 pydantic import BaseModel
from datetime import datetime, date
@ -164,6 +164,45 @@ class CreateSagFromEmailRequest(BaseModel):
priority: Optional[str] = None
ansvarlig_bruger_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
relation_type: str = "mail"
@ -369,7 +408,7 @@ async def list_emails(
@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"""
try:
query = """
@ -397,9 +436,14 @@ async def get_email(email_id: int):
attachments = execute_query(att_query, (email_id,))
email_data['attachments'] = attachments or []
# Mark as read
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
execute_update(update_query, (email_id,))
user_id = getattr(request.state, "user_id", None)
linked_case_id = email_data.get("linked_case_id")
can_mark_read = _can_user_mark_case_email_read(user_id, linked_case_id)
if not bool(email_data.get("is_read")) and can_mark_read:
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
execute_update(update_query, (email_id,))
email_data["is_read"] = True
return email_data
@ -410,6 +454,38 @@ async def get_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/emails/{email_id:int}/read-state")
async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate, request: Request):
"""Toggle read/unread state for an email.
Marking as read on case-linked emails is restricted to case assignee user/group.
"""
try:
row = execute_query_single(
"SELECT id, linked_case_id, is_read FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Email not found")
user_id = getattr(request.state, "user_id", None)
if payload.is_read:
can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id"))
if not can_mark_read:
raise HTTPException(status_code=403, detail="Email kan ikke markeres som laest: sag er ikke tildelt dig/din gruppe")
execute_update(
"UPDATE email_messages SET is_read = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(payload.is_read, email_id),
)
return {"success": True, "email_id": email_id, "is_read": payload.is_read}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating read-state for email {email_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/mark-processed")
async def mark_email_processed(email_id: int):
"""Mark email as processed and move to 'Processed' folder"""

View File

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

View File

@ -79,6 +79,35 @@ def _extract_full_name(payload: Any) -> Optional[str]:
return None
def _extract_login_candidates(payload: Any) -> List[str]:
raw = _extract_first_str(
payload,
["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"]
)
if not raw:
return []
candidates: List[str] = []
def _add(value: str) -> None:
v = (value or "").strip().lower()
if v and v not in candidates:
candidates.append(v)
_add(raw)
# DOMAIN\\user or provider/user -> user
if "\\" in raw:
_add(raw.split("\\")[-1])
if "/" in raw:
_add(raw.split("/")[-1])
# email local-part fallback
if "@" in raw:
_add(raw.split("@", 1)[0])
return candidates
def _detect_asset_type(payload: Any) -> str:
device_type = _extract_first_str(payload, ["deviceType", "type"])
if device_type:
@ -104,6 +133,57 @@ def _match_contact(full_name: str, company: str) -> Optional[int]:
return None
def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]:
if not login_candidate:
return None
# Try scoped match first when company is known to reduce false positives.
if company:
scoped_query = """
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
scoped = execute_query(scoped_query, (login_candidate, company))
if scoped:
return scoped[0]["id"]
scoped_local_part_query = """
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
scoped_local_part = execute_query(scoped_local_part_query, (login_candidate, company))
if scoped_local_part:
return scoped_local_part[0]["id"]
email_query = """
SELECT id
FROM contacts
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
LIMIT 1
"""
by_email = execute_query(email_query, (login_candidate,))
if by_email:
return by_email[0]["id"]
local_part_query = """
SELECT id
FROM contacts
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
LIMIT 1
"""
by_local_part = execute_query(local_part_query, (login_candidate,))
if by_local_part:
return by_local_part[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
@ -213,7 +293,14 @@ async def sync_eset_hardware() -> None:
full_name = _extract_full_name(details)
company = _extract_company(details)
login_candidates = _extract_login_candidates(details)
contact_id = _match_contact(full_name, company) if full_name and company else None
if not contact_id:
for login_candidate in login_candidates:
contact_id = _match_contact_by_login(login_candidate, company)
if contact_id:
break
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

View File

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

View File

View File

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

View File

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,59 @@ logger = logging.getLogger(__name__)
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:
if not deadline_value:
return False
@ -128,7 +181,15 @@ async def sager_liste(
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
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
LEFT JOIN customers c ON s.customer_id = c.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
LIMIT 1
) 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
WHERE s.deleted_at IS NULL
"""
@ -196,10 +265,26 @@ async def sager_liste(
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
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
LEFT JOIN customers c ON s.customer_id = c.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
"""
fallback_params = []
@ -289,6 +374,7 @@ async def sager_liste(
"toggle_include_deferred_url": toggle_include_deferred_url,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
"current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_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),
"assignment_users": [],
"assignment_groups": [],
"current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_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(),
})
@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)
async def sag_varekob_salg(request: Request):
"""Display orders overview for all purchases and sales."""

View File

@ -124,6 +124,18 @@
[data-bs-theme="dark"] .selected-item button {
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>
{% endblock %}
@ -139,6 +151,8 @@
</div>
<div class="card-body p-4">
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
<!-- Notifications -->
<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>
@ -311,6 +325,79 @@
let contactSearchTimeout;
let successAlertTimeout;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
function showSuccessAlert(message, duration = 3000) {
@ -436,6 +523,7 @@
document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none');
renderSelections();
loadCreateTopAlertsForCustomer(id);
// Show notification
if (!skipAlert) {
@ -447,6 +535,7 @@
selectedCustomer = null;
document.getElementById('customer_id').value = '';
renderSelections();
loadCreateTopAlertsForCustomer(null);
}
async function selectContact(id, name) {
@ -460,7 +549,7 @@
// Check for associated company (auto-select if single match)
try {
const response = await fetch(`/api/v1/contacts/${id}`);
const response = await fetch(`/api/v1/contacts/${id}`, { credentials: 'include' });
if (response.ok) {
const data = await response.json();
selectedContactsCompanies[id] = data.companies || [];
@ -530,7 +619,7 @@
if (telefoniPrefill.customerId && !selectedCustomer) {
try {
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`);
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`, { credentials: 'include' });
if (customerRes.ok) {
const customer = await customerRes.json();
const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`;
@ -543,7 +632,7 @@
if (telefoniPrefill.contactId) {
try {
const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`);
const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`, { credentials: 'include' });
if (!res.ok) return;
const c = await res.json();
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`;
@ -598,7 +687,7 @@
try {
const responses = await Promise.all(
contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`))
contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`, { credentials: 'include' }))
);
const datasets = await Promise.all(responses.map(r => r.ok ? r.json() : []));
const merged = new Map();
@ -686,6 +775,7 @@
try {
const response = await fetch('/api/v1/hardware/quick', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
@ -723,6 +813,7 @@
try {
const response = await fetch('/api/v1/customers', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() })
});
@ -738,6 +829,7 @@
const linkResponses = await Promise.all(contactIds.map(contactId =>
fetch(`/api/v1/contacts/${contactId}/companies`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: created.id, is_primary: false })
})
@ -772,6 +864,7 @@
};
const response = await fetch('/api/v1/contacts', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
@ -886,6 +979,7 @@
try {
const response = await fetch('/api/v1/sag', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@ -897,9 +991,7 @@
const contactPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'})
})
credentials: 'include',
);
await Promise.all(contactPromises);
@ -909,6 +1001,7 @@
try {
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: result.id,
@ -925,6 +1018,7 @@
const linkPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
})

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,41 @@
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 {
font-weight: 600;
color: var(--text-primary);
@ -266,11 +301,30 @@
margin-bottom: 1rem;
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>
{% endblock %}
{% block content %}
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);">
@ -382,6 +436,12 @@
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %}
<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 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 '-' }}
@ -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;">
<td>
<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 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 '-' }}
@ -508,6 +574,67 @@
</div>
<script>
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
function toggleTreeNode(event, sagId) {
event.stopPropagation();
@ -636,5 +763,9 @@
}
loadTypeFilters();
if (topAlertCustomerId) {
loadSagTopAlertsForCustomer(topAlertCustomerId);
}
</script>
{% endblock %}

View File

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

View File

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

View File

@ -20,15 +20,29 @@ class TelefoniService:
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
@staticmethod
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
if not suffix8:
def find_contact_by_phone(number: Optional[str]) -> Optional[dict]:
"""Two-step lookup: full normalised number first, 8-digit suffix as fallback."""
from app.modules.telefoni.backend.utils import phone_digits_full, phone_suffix_8
full = phone_digits_full(number)
suffix = phone_suffix_8(number)
if not full and not suffix:
return None
query = """
_contact_cte = """
SELECT
c.id,
c.first_name,
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
FROM contact_companies cc
@ -36,22 +50,66 @@ class TelefoniService:
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
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
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:
return None
return {
"id": row["id"],
"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"),
}
@staticmethod
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
"""Deprecated: use find_contact_by_phone(). Kept for backward compatibility."""
return TelefoniService.find_contact_by_phone(suffix8)
@staticmethod
def upsert_call(
*,
@ -103,7 +161,13 @@ class TelefoniService:
"""
UPDATE telefoni_opkald
SET ended_at = NOW(),
duration_sec = %s
duration_sec = COALESCE(
%s,
CASE
WHEN started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - started_at))::int, 0)
ELSE NULL
END
)
WHERE callid = %s
RETURNING id
""",

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -1,20 +1,59 @@
"""
CVR.dk API service for looking up Danish company information
Free public API - no authentication required
Adapted from OmniSync for BMC Hub
CVR service for looking up Danish company information.
Primary provider: FirmaAPI (authenticated).
Legacy fallback: cvrapi.dk when no FirmaAPI key is configured.
"""
import asyncio
import aiohttp
import logging
from typing import Optional, Dict
from app.core.config import settings
logger = logging.getLogger(__name__)
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]:
"""
@ -33,42 +72,43 @@ class CVRService:
clean_name = company_name.strip()
try:
params = {
'search': clean_name,
'country': 'dk'
}
if self.has_firmaapi_key:
async with aiohttp.ClientSession() as session:
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 session.get(
f"{self.BASE_URL}",
f"{self.LEGACY_BASE_URL}",
params=params,
timeout=aiohttp.ClientTimeout(total=10)
timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status == 200:
data = await response.json()
if data and 'vat' in data:
logger.info(f"✅ Found CVR {data['vat']} for '{company_name}'")
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
if data and "vat" in data:
return self._normalize_payload(data)
return None
except asyncio.TimeoutError:
logger.error(f"⏱️ CVR API timeout for '{company_name}'")
@ -99,31 +139,37 @@ class CVRService:
return None
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 session.get(
f"{self.BASE_URL}",
params={'vat': cvr_clean, 'country': 'dk'},
timeout=aiohttp.ClientTimeout(total=10)
f"{self.LEGACY_BASE_URL}",
params={"vat": cvr_clean, "country": "dk"},
timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status == 200:
data = await response.json()
if data and 'vat' in data:
logger.info(f"✅ Validated CVR {cvr_clean}")
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')
}
if data and "vat" in data:
logger.info("✅ Validated CVR %s via legacy CVR API", cvr_clean)
return self._normalize_payload(data)
return None
except Exception as e:

View File

@ -731,12 +731,9 @@ class EmailService:
Priority:
1) First References token (root message id)
2) In-Reply-To
3) Message-ID
3) Explicit provider thread key (e.g. Graph conversationId)
4) Message-ID
"""
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
if explicit_thread_key:
return explicit_thread_key
reference_ids = self._extract_reference_ids(email_data.get("email_references"))
if reference_ids:
return reference_ids[0]
@ -745,6 +742,10 @@ class EmailService:
if in_reply_to:
return in_reply_to
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
if explicit_thread_key:
return explicit_thread_key
return self._normalize_message_id_value(email_data.get("message_id"))
def _parse_email_date(self, date_str: str) -> datetime:
@ -766,11 +767,99 @@ class EmailService:
result = execute_query(query, (message_id,))
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]:
"""Save email to database"""
try:
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:
query = """
INSERT INTO email_messages

View File

@ -11,10 +11,12 @@ import re
import json
import hashlib
import shutil
import io
from pathlib import Path
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.services.email_activity_logger import email_activity_logger
@ -38,6 +40,8 @@ class EmailWorkflowService:
'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:
"""
Execute all matching workflows for an email
@ -91,11 +95,16 @@ class EmailWorkflowService:
logger.info("✅ Bankruptcy system workflow executed successfully")
# 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
HARD_SKIP = {'newsletter', 'spam'}
should_try_helpdesk = (
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
or has_hint
classification not in HARD_SKIP
and (
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
or has_hint
)
)
if should_try_helpdesk:
@ -223,12 +232,16 @@ class EmailWorkflowService:
return domain or None
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
"""Return True when email has explicit routing hints (SAG or thread headers/key)."""
if self._extract_sag_id(email_data):
"""Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers).
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
explicit_thread_key = self._normalize_message_id(email_data.get('thread_key'))
if explicit_thread_key:
if self._extract_sag_id(email_data):
return True
if self._normalize_message_id(email_data.get('in_reply_to')):
@ -239,7 +252,33 @@ class EmailWorkflowService:
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]:
# First try structured BMCid (most reliable)
bmc_id = self._extract_bmc_id(email_data)
if bmc_id:
return bmc_id['sag_id']
candidates = [
email_data.get('subject') 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.:
# - [SAG-53] (hidden/subject prefix)
# - SAG-53
# - SAG #53
# - Sag 53
sag_patterns = [
r'\[SAG-(\d+)\]',
r'\bSAG-(\d+)\b',
r'\bSAG\s*#\s*(\d+)\b',
r'\bSAG\s+(\d+)\b',
r'\bBMCid\s*:\s*s(\d+)t\d+\b',
]
for value in candidates:
@ -299,10 +339,7 @@ class EmailWorkflowService:
return list(dict.fromkeys(tokens))
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
"""Derive stable conversation key: root References -> In-Reply-To -> Message-ID."""
explicit = self._normalize_message_id(email_data.get('thread_key'))
if explicit:
return explicit
"""Derive stable conversation key: root References -> In-Reply-To -> explicit -> Message-ID."""
ref_ids = self._extract_reference_message_ids(email_data.get('email_references'))
if ref_ids:
@ -312,6 +349,10 @@ class EmailWorkflowService:
if in_reply_to:
return in_reply_to
explicit = self._normalize_message_id(email_data.get('thread_key'))
if explicit:
return explicit
return self._normalize_message_id(email_data.get('message_id'))
def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]:
@ -326,11 +367,14 @@ class EmailWorkflowService:
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE em.deleted_at IS NULL
AND LOWER(TRIM(COALESCE(em.thread_key, ''))) = %s
AND (
LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) = %s
)
ORDER BY se.created_at DESC
LIMIT 1
""",
(thread_key,)
(thread_key, thread_key)
)
return rows[0]['sag_id'] if rows else None
except Exception:
@ -356,11 +400,23 @@ class EmailWorkflowService:
)
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]]:
if not domain:
return None
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}"
query = """
@ -377,6 +433,114 @@ class EmailWorkflowService:
rows = execute_query(query, (domain, domain_alt))
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:
execute_update(
"""
@ -389,6 +553,379 @@ class EmailWorkflowService:
(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:
"""Return only the newest reply content (remove quoted history/signatures)."""
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 = self._find_sag_id_from_thread_headers(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
sag_id = None
@ -512,6 +1084,11 @@ class EmailWorkflowService:
routing_source = 'thread_headers'
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 and sag_id != sag_id_from_tag:
logger.warning(
@ -527,40 +1104,7 @@ class EmailWorkflowService:
# 1) Existing SAG via subject/headers
if sag_id:
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)
)
return {
'status': 'completed',
'action': 'updated_existing_sag',
'sag_id': sag_id,
'customer_id': case.get('customer_id'),
'routing_source': routing_source
}
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
# 2) No SAG id -> create only if sender domain belongs to known customer
sender_domain = self._extract_sender_domain(email_data)
@ -588,6 +1132,7 @@ class EmailWorkflowService:
(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'])
return {
'status': 'completed',

View File

@ -102,7 +102,7 @@ class ReminderNotificationService:
)
# 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_email = user[0]['email'] if user else None

View 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,
}

View File

@ -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.
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
defaults = {
@ -259,6 +300,14 @@ async def get_setting_categories():
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"])
async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)"""

View File

@ -4,6 +4,7 @@ Settings Frontend Views
from datetime import datetime
from pathlib import Path
import re
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
@ -15,6 +16,183 @@ from app.core.database import get_db_connection, release_db_connection, execute_
router = APIRouter()
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"])
async def settings_page(request: Request):
@ -73,6 +251,36 @@ class MigrationExecution(BaseModel):
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"])
def execute_migration(payload: MigrationExecution):
"""Execute a migration SQL file"""

View File

@ -20,6 +20,11 @@
.command-actions .btn {
min-width: 120px;
}
.migration-status-badge {
min-width: 72px;
display: inline-block;
text-align: center;
}
</style>
{% endblock %}
@ -45,7 +50,12 @@
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<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 class="card-body">
{% if migrations and migrations|length > 0 %}
@ -54,6 +64,7 @@
<thead>
<tr>
<th>Fil</th>
<th>Status</th>
<th>Størrelse</th>
<th>Sidst ændret</th>
<th class="text-end">Handling</th>
@ -65,6 +76,9 @@
<td>
<strong>{{ migration.name }}</strong>
</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.modified }}</td>
<td class="text-end d-flex gap-2 justify-content-end">
@ -159,7 +173,6 @@
async function runMigration(migrationName, button) {
const feedback = document.getElementById('migrationFeedback');
const url = '/settings/migrations/execute';
button.disabled = true;
feedback.className = 'alert alert-info mt-3';
@ -167,14 +180,38 @@
feedback.classList.remove('d-none');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: migrationName })
});
const urls = buildMigrationActionUrls('execute');
const attempts = [];
let data = null;
let lastError = null;
const data = await response.json();
if (!response.ok) throw new Error(data.detail || data.message);
for (const url of urls) {
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.innerHTML = `<strong>Migration kørt</strong><br><pre class="mb-0">${data.output}</pre>`;
@ -185,5 +222,112 @@
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>
{% endblock %}

View File

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

View File

@ -220,6 +220,7 @@
<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="/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="#">Leads</a></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="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
@ -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>
</ul>
</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)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
<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>
</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">
<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">
@ -406,6 +413,21 @@
</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 -->
<div id="salesResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
@ -559,8 +581,52 @@
document.addEventListener('DOMContentLoaded', () => {
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');
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
let searchTimeout;
if (globalSearchInput) {
@ -582,6 +648,9 @@
navigateResults(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (navigateToSagFromScan(e.target.value)) {
return;
}
selectCurrentResult();
}
});
@ -592,15 +661,7 @@
// Cmd+K / Ctrl+K for global search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
console.log('Cmd+K pressed - opening search modal'); // Debug
searchModal.show();
setTimeout(() => {
if (globalSearchInput) {
globalSearchInput.focus();
}
loadLiveStats();
loadRecentActivity();
}, 300);
openGlobalSearchModal();
}
// '+' key for QuickCreate (not in input fields)
@ -650,6 +711,7 @@
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').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('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
async function performGlobalSearch(query) {
if (navigateToSagFromScan(query)) {
return;
}
if (!query || query.trim().length < 2) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('crmResults').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('financeResults')) document.getElementById('financeResults').style.display = 'none';
return;
@ -887,6 +978,51 @@
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
try {
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-pane fade show active" id="profile-overview" role="tabpanel" tabindex="0">
<div class="alert alert-info small mb-0">
Profilinformation hentes fra din konto. Flere felter kan tilføjes her senere.
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Fuldt navn</label>
<input type="text" class="form-control" id="prof_full_name" placeholder="Dit navn">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Titel / rolle</label>
<input type="text" class="form-control" id="prof_title" placeholder="f.eks. Teknikker">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Mobilnummer</label>
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
</div>
<div class="col-12">
<label class="form-label fw-semibold">
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
</label>
<div class="form-text mb-2">Tilføj alle maskiner du bruger som teknikker — bruges til automatisk at genkende dig i remote sessions.</div>
<div id="prof-anydesk-chips" class="d-flex flex-wrap gap-2 mb-2"></div>
<div class="input-group" style="max-width:400px">
<input type="text" class="form-control font-monospace" id="prof_anydesk_new_id"
placeholder="AnyDesk ID (tal)" autocomplete="off"
onkeydown="if(event.key==='Enter'){event.preventDefault();addAnyDeskId()}">
<input type="text" class="form-control" id="prof_anydesk_new_label"
placeholder="Navn (valgfri, f.eks. Laptop)" style="max-width:160px"
onkeydown="if(event.key==='Enter'){event.preventDefault();addAnyDeskId()}">
<button class="btn btn-outline-primary" type="button" onclick="addAnyDeskId()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<div class="col-12 pt-1">
<button class="btn btn-primary btn-sm" onclick="saveUserProfile()">
<i class="bi bi-check-lg me-1"></i>Gem profil
</button>
<span id="prof-save-status" class="ms-2 small text-success" style="display:none">Gemt ✓</span>
</div>
</div>
</div>
@ -1297,12 +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">&times;</button>
</span>`).join('')
: '<span class="text-secondary small">Ingen IDs tilføjet endnu</span>';
} catch(e) { /* silent */ }
}
async function addAnyDeskId() {
const id = document.getElementById('prof_anydesk_new_id').value.trim();
const label = document.getElementById('prof_anydesk_new_label').value.trim();
if (!id) return;
try {
const res = await fetch('/api/v1/auth/me/anydesk-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ anydesk_id: id, label: label || null })
});
if (!res.ok) { const e = await res.json(); alert(e.detail || 'Fejl'); return; }
document.getElementById('prof_anydesk_new_id').value = '';
document.getElementById('prof_anydesk_new_label').value = '';
loadAnyDeskChips();
} catch(e) { alert('Fejl: ' + e.message); }
}
async function removeAnyDeskId(entryId) {
try {
const res = await fetch(`/api/v1/auth/me/anydesk-ids/${entryId}`, {
method: 'DELETE', credentials: 'include'
});
if (!res.ok) throw new Error('Fejl');
loadAnyDeskChips();
} catch(e) { alert('Fejl: ' + e.message); }
}
async function saveUserProfile() {
const payload = {
full_name: document.getElementById('prof_full_name').value || null,
title: document.getElementById('prof_title').value || null,
phone: document.getElementById('prof_phone').value || null,
};
try {
const res = await fetch('/api/v1/auth/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
const statusEl = document.getElementById('prof-save-status');
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 3000);
} catch (e) { alert('Fejl: ' + e.message); }
}
document.addEventListener('DOMContentLoaded', () => {
const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => {
loadReminderPreferences();
loadProfileReminders();
loadUserProfile();
});
}
});

View File

@ -95,6 +95,17 @@ class TModuleTimeBase(BaseModel):
original_hours: Decimal = Field(..., gt=0, description="Original timer")
worked_date: Optional[date] = None
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):
@ -110,6 +121,9 @@ class TModuleTimeUpdate(BaseModel):
billable: Optional[bool] = None
is_travel: Optional[bool] = None
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):

View File

@ -52,6 +52,117 @@ logger = logging.getLogger(__name__)
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
# ============================================================================
@ -1758,6 +1869,476 @@ async def uninstall_module(
# 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"])
async def get_time_entries_for_sag(sag_id: int):
"""Get time entries linked to a Hub Sag (Case)."""
@ -1790,21 +2371,37 @@ async def create_internal_time_entry(
description = entry.get("description")
hours = entry.get("original_hours")
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 = (
(current_user or {}).get("username")
or (current_user or {}).get("full_name")
or "Hub User"
)
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")
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
work_type = entry.get("work_type", "support")
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:
raise HTTPException(status_code=400, detail="sag_id and original_hours required")
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
if not customer_id:
@ -1985,12 +2582,18 @@ async def create_internal_time_entry(
sag_id, solution_id, customer_id, description,
original_hours, worked_date, user_name,
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 (
%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 *
"""
@ -1998,7 +2601,10 @@ async def create_internal_time_entry(
sag_id, solution_id, customer_id, description,
hours, worked_date, user_name,
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)
if result:

22
apply_migration_150.py Normal file
View File

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

20
check_threads.sql Normal file
View 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;

View File

@ -50,7 +50,7 @@ services:
environment:
# 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}
- ENABLE_RELOAD=false
- ENABLE_RELOAD=${ENABLE_RELOAD:-true}
- APIGW_TOKEN=${APIGW_TOKEN}
- APIGATEWAY_URL=${APIGATEWAY_URL}
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}

1
final_wc.txt Normal file
View File

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

18
fix_domcontent.py Normal file
View File

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

View File

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

0
fix_js2.py Normal file
View File

13
fix_tab.py Normal file
View File

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

22
fix_time_modal.py Normal file
View File

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

223
fix_timeline_clean.py Normal file
View File

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

195
fix_timeline_colors.py Normal file
View File

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

9
get_js.py Normal file
View File

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

6
get_saveTime.py Normal file
View File

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

23
main.py
View File

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

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View 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.

View 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) != '';

View 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
View File

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

View File

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

View File

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

323
patch_detail.py Normal file
View File

@ -0,0 +1,323 @@
import re
import sys
def patch():
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
text = f.read()
css_start = text.find('.time-v1-user-section {')
css_end = text.find('</style>', css_start)
css_new = """
.time-v1-calendar-container {
background: var(--bg-surface, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
margin-bottom: 2rem;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
}
.time-v1-calendar-header {
background: var(--bg-element, #f8f9fa);
border-bottom: 1px solid var(--border-color, #e0e0e0);
padding: 12px 20px;
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color);
}
.time-v1-calendar-grid {
display: flex;
position: relative;
overflow-x: auto;
}
.time-v1-time-axis {
width: 60px;
flex-shrink: 0;
border-right: 1px solid var(--border-color, #f0f0f0);
position: relative;
background: var(--bg-element, #fafafa);
padding-top: 40px;
}
.time-v1-hour-marker {
position: absolute;
width: 100%;
text-align: center;
font-size: 0.75rem;
color: var(--text-secondary);
transform: translateY(-50%);
}
.time-v1-tech-col {
flex: 1;
min-width: 250px;
border-right: 1px solid var(--border-color, #f0f0f0);
position: relative;
}
.time-v1-tech-col:last-child {
border-right: none;
}
.time-v1-tech-header {
text-align: center;
padding: 8px;
height: 40px;
font-weight: 600;
font-size: 0.85rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-element, #f8f9fa);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
position: sticky;
top: 0;
z-index: 50;
color: var(--text-color);
}
.time-v1-tech-body {
position: relative;
height: 600px; /* 10h * 60Px = 600px */
background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px);
background-size: 100% 60px;
}
.time-v1-entry-block {
position: absolute;
left: 4px;
right: 4px;
border-radius: 6px;
padding: 6px 8px;
font-size: 0.8rem;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s;
border-left: 4px solid var(--bs-secondary);
background: var(--bg-surface, #fff);
cursor: grab;
z-index: 10;
}
.time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; }
.time-v1-entry-block:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
z-index: 20;
}
.time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; }
.time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; }
.time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; }
.time-v1-entry-time {
font-weight: 600;
font-size: 0.75rem;
margin-bottom: 2px;
color: var(--text-color);
}
.time-v1-entry-desc {
color: var(--text-secondary);
font-size: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.time-v1-unplaced-container {
padding: 12px 20px;
border-top: 1px solid var(--border-color);
background: var(--bg-element);
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.time-v1-unplaced-item {
background: var(--bg-surface);
border: 1px solid var(--border-color);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-color);
}
"""
if css_start != -1 and css_end != -1:
text = text[:css_start] + css_new + text[css_end:]
print("Replaced CSS.")
js_start = text.find('function renderTimeV1Timeline(entries) {')
js_end = text.find('async function loadTimeTrackingTab() {', js_start)
js_new = """function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns');
if (!timeline) return;
if (!entries || entries.length === 0) {
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
return;
}
const START_HOUR = 7;
const TOTAL_HOURS = 10; // 07:00 to 17:00
const HOUR_HEIGHT = 60; // px
const groupedByDate = {};
entries.forEach((entry) => {
let dateKey = 'Ukendt dato';
if (entry.start_tid) {
dateKey = entry.start_tid.split('T')[0];
} else if (entry.worked_date) {
dateKey = entry.worked_date;
} else if (entry.created_at) {
dateKey = entry.created_at.split('T')[0];
}
// Keep only first 10 chars for proper grouping if it's an ISO timestamp
if (dateKey.length > 10) dateKey = dateKey.substring(0, 10);
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
groupedByDate[dateKey].push(entry);
});
const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a));
let html = '';
sortedDates.forEach(dateStr => {
const dayEntries = groupedByDate[dateStr];
let formattedDateLab = dateStr;
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
formattedDateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
formattedDateLab = formattedDateLab.charAt(0).toUpperCase() + formattedDateLab.slice(1);
}
} catch(e){}
const techs = {};
const unplaced = [];
dayEntries.forEach(entry => {
const tech = entry.bruger_navn || entry.user_name || 'Ukendt';
if (!techs[tech]) techs[tech] = [];
if (!entry.start_tid || entry.start_tid === null) {
unplaced.push(entry);
} else {
techs[tech].push(entry);
}
});
const techNames = Object.keys(techs).sort();
html += `
<div class="time-v1-calendar-container">
<div class="time-v1-calendar-header">
<i class="bi bi-calendar3 text-primary"></i> ${formattedDateLab}
</div>
<div class="time-v1-calendar-grid">
<div class="time-v1-time-axis">
`;
for (let i = 0; i <= TOTAL_HOURS; i++) {
const h = START_HOUR + i;
const top = i * HOUR_HEIGHT;
html += `<div class="time-v1-hour-marker" style="top: ${top}px">${h.toString().padStart(2, '0')}:00</div>`;
}
html += `</div>`;
techNames.forEach(tech => {
html += `
<div class="time-v1-tech-col" data-tech="${escapeHtml(tech)}" data-date="${dateStr}">
<div class="time-v1-tech-header">
<i class="bi bi-person-circle text-secondary"></i> ${escapeHtml(tech)}
</div>
<div class="time-v1-tech-body">
`;
techs[tech].forEach(entry => {
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
const status = entry.entry_status || entry.status || 'kladde';
let cssClass = 'time-v1-entry-kladde';
if (status === 'afventer' || status === 'pending') cssClass = 'time-v1-entry-pending';
if (status === 'godkendt' || status === 'billed' || status === 'approved' || entry.fakturerbar_tid_min > 0) cssClass = 'time-v1-entry-godkendt';
const startObj = new Date(entry.start_tid);
let durationMin = 30; // default length
if (entry.faktisk_tid_min) {
durationMin = parseInt(entry.faktisk_tid_min);
} else if (entry.original_hours || entry.timer) {
durationMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
}
let startH = startObj.getHours();
let startM = startObj.getMinutes();
if (startH < START_HOUR) {
durationMin -= ((START_HOUR * 60) - (startH * 60 + startM));
startH = START_HOUR;
startM = 0;
}
let topPx = ((startH - START_HOUR) + (startM / 60)) * HOUR_HEIGHT;
let heightPx = (durationMin / 60) * HOUR_HEIGHT;
if (topPx < 0) topPx = 0;
if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) {
heightPx = (TOTAL_HOURS * HOUR_HEIGHT) - topPx;
}
if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
const endObj = new Date(startObj.getTime() + durationMin * 60000);
const timeStr = `${startObj.getHours().toString().padStart(2,'0')}:${startObj.getMinutes().toString().padStart(2,'0')} - ${endObj.getHours().toString().padStart(2,'0')}:${endObj.getMinutes().toString().padStart(2,'0')}`;
html += `
<div class="time-v1-entry-block ${cssClass}" style="top: ${topPx}px; height: ${heightPx}px;" title="${desc}">
<div class="time-v1-entry-time">${timeStr}</div>
<div class="time-v1-entry-desc text-wrap">${desc}</div>
</div>
`;
}
});
html += `
</div>
</div>
`;
});
html += `</div>`;
if (unplaced.length > 0) {
html += `<div class="time-v1-unplaced-container">
<span class="text-muted small fw-semibold"><i class="bi bi-clock-history"></i> Uden tidsrum:</span>
`;
unplaced.forEach(u => {
const userName = escapeHtml(u.bruger_navn || u.user_name || 'Ukendt');
const hrs = u.original_hours || u.timer || 0;
html += `<div class="time-v1-unplaced-item">
<i class="bi bi-person text-secondary"></i> ${userName} &bull; ${hrs}t
</div>`;
});
html += `</div>`;
}
html += `</div>`;
});
timeline.innerHTML = html;
}
"""
if js_start != -1 and js_end != -1:
text = text[:js_start] + js_new + text[js_end:]
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.write(text)
print("Replaced JS and saved detail.html.")
else:
print("JS function not found or end not found.")
patch()

741
patch_everything.py Normal file
View File

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

207
patch_time_form.py Normal file
View File

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

171
patch_time_modal.py Normal file
View File

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

1
patcher.py Normal file
View File

@ -0,0 +1 @@
import os

9
print_saveTime.py Normal file
View File

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

View File

@ -20,3 +20,6 @@ APScheduler==3.10.4
pdfplumber==0.11.4
av==13.1.0
Pillow==11.0.0
brother_ql==0.9.4
pyzbar==0.1.9
pypdfium2==4.30.0

1
result.txt Normal file
View File

@ -0,0 +1 @@
2

15
run_anydesk_import.py Normal file
View File

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

43
script_0.js Normal file
View File

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

1433
script_1.js Normal file

File diff suppressed because it is too large Load Diff

918
script_10.js Normal file
View File

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

186
script_11.js Normal file
View File

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

578
script_2.js Normal file
View File

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

208
script_3.js Normal file
View File

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

356
script_4.js Normal file
View File

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

344
script_5.js Normal file
View File

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

235
script_6.js Normal file
View File

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

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