Refactor code structure for improved readability and maintainability; remove redundant code blocks and optimize function calls.

This commit is contained in:
Christian 2026-10-20 07:18:57 +02:00
parent 85e019783b
commit 3b61c46ea4
93 changed files with 7523 additions and 229 deletions

View File

@ -477,6 +477,7 @@
<select id="reg-customer" onchange="onCustomerChange()">
<option value="">– vælg kunde –</option>
</select>
<div class="small text-muted mt-1">AnyDesk-ID gemmes på kontaktpersonens hardware. Findes maskinen ikke, oprettes en PC under firmaet. Koblingen bruges også på tidligere uregistrerede sessioner. Sag og noter gælder kun denne session.</div>
</div>
<!-- Kontakt -->
@ -718,8 +719,8 @@ async function loadHardware() {
async function loadCustomers() {
try {
const data = await api('/api/v1/customers/?limit=500');
customerList = data.customers || data || [];
const data = await api('/api/v1/customers/?limit=500&is_active=true');
customerList = (data.customers || data || []).filter(customer => customer.is_active === true);
} catch(e) { /* silent */ }
}
@ -1036,8 +1037,8 @@ async function loadContactsForCustomer(customerId, selectedId) {
sel.innerHTML = '<option value="">– ingen / vælg –</option>';
try {
const url = customerId
? `/api/v1/contacts/?customer_id=${customerId}&limit=300`
: '/api/v1/contacts/?limit=300';
? `/api/v1/contacts/?customer_id=${customerId}&limit=100&is_active=true`
: '/api/v1/contacts/?limit=100&is_active=true';
const data = await api(url);
currentContactList = data.contacts || data || [];
filterContactOptions();
@ -1138,13 +1139,17 @@ async function saveRegModal() {
const params = new URLSearchParams();
if (hw) params.append('hardware_asset_id', hw);
if (cust) params.append('customer_id', cust);
if (cust) params.append('remember_machine_link', 'true');
if (contact) params.append('contact_id', contact);
if (sag) params.append('sag_id', sag);
if (notes) params.append('notes', notes);
try {
await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
toast('✅ Session registreret', 'success');
const result = await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
toast(result.remembered
? `✅ AnyDesk-ID gemt på hardware · ${result.linked_sessions || 0} andre sessioner opdateret`
: '✅ Session registreret', 'success');
if (result.remembered) await loadHardware();
document.getElementById('drawer-backdrop').classList.remove('open');
document.getElementById('session-drawer').classList.remove('open');
await loadSessions();

View File

@ -878,6 +878,24 @@
</div>
<!-- Worklog Suggestion Modal -->
<div class="modal fade" id="remoteCaseModal" tabindex="-1" aria-labelledby="remoteCaseTitle">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="remoteCaseTitle">Tilknyt sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<label class="form-label" for="remoteCaseSearch">Søg på sagsnummer eller titel</label>
<input class="form-control mb-2" id="remoteCaseSearch" type="search" placeholder="Skriv fx 1234 eller Outlook" oninput="scheduleRemoteCaseSearch()">
<div class="small text-muted mb-3">Kun sager fra sessionens firma. Klik på en sag for at tilknytte den.</div>
<div id="remoteCaseError" class="alert alert-danger d-none" role="alert"></div>
<div id="remoteCaseResults" class="list-group"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="worklogSuggestionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
@ -931,6 +949,11 @@ let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
let pendingContactEmailSuggestions = [];
let remoteSessionRows = [];
let remoteCaseSessionId = null;
let remoteCaseTimer = null;
let remoteCaseSearchVersion = 0;
let remoteCaseSaving = false;
document.addEventListener('DOMContentLoaded', () => {
loadContact();
@ -1794,7 +1817,7 @@ async function loadContactHardware() {
container.classList.remove('d-none');
try {
const response = await fetch(`/api/v1/hardware/by-contact/${contactId}`);
const response = await fetch(`/api/v1/hardware/by-contact/${contactId}?direct_only=true`);
if (!response.ok) {
throw new Error('Kunne ikke hente hardware');
}
@ -1826,6 +1849,10 @@ async function loadContactHardware() {
const serial = item.serial_number || item.customer_asset_id || '—';
const location = locationsById[item.current_location_id] || item.location_name || '—';
const status = item.status || '—';
const anydeskId = String(item.anydesk_id || '').replace(/[\s-]/g, '');
const anydeskAction = /^\d+$/.test(anydeskId)
? `<a class="btn btn-sm btn-outline-danger me-1" href="anydesk:${anydeskId}" title="Åbn AnyDesk (${anydeskId})" aria-label="Åbn AnyDesk (${anydeskId})"><i class="bi bi-display me-1"></i>AnyDesk</a>`
: '';
return `
<tr>
<td class="fw-semibold">${escapeHtml(assetLabel)}</td>
@ -1834,6 +1861,7 @@ async function loadContactHardware() {
<td>${escapeHtml(location)}</td>
<td>${escapeHtml(status)}</td>
<td class="text-end">
${anydeskAction}
<a class="btn btn-sm btn-outline-primary" href="/hardware/${item.id}">
<i class="bi bi-eye"></i>
</a>
@ -2346,6 +2374,7 @@ async function loadRemoteSessions() {
}
function displayRemoteSessions(sessions) {
remoteSessionRows = sessions || [];
const container = document.getElementById('remoteSessionsContainer');
if (!container) return;
@ -2354,39 +2383,110 @@ function displayRemoteSessions(sessions) {
return;
}
container.innerHTML = sessions.map(session => {
const statusLabels = {
draft: 'Kladde', pending: 'Afventer', active: 'Aktiv', completed: 'Afsluttet',
failed: 'Fejlet', cancelled: 'Annulleret'
};
const rows = sessions.map(session => {
const badgeClass = getSessionBadgeClass(session.status);
const duration = session.duration_minutes ? `${session.duration_minutes} min` : '—';
const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '-';
const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '-';
const duration = session.duration_minutes != null
? `${Number(session.duration_minutes).toLocaleString('da-DK')} min` : '—';
const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '—';
const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '—';
const caseLink = session.sag_id
? `<a href="/sag/${Number(session.sag_id)}" class="text-decoration-none"><strong>#${Number(session.sag_id)}</strong>${session.sag_title ? `<span class="d-block small">${escapeHtml(session.sag_title)}</span>` : ''}</a>`
: '<span class="text-muted">Ingen sag</span>';
const linkButton = session.session_link
? `<a class="btn btn-sm btn-outline-primary" href="${session.session_link}" target="_blank">Åbn Link</a>`
? `<a class="btn btn-sm btn-outline-primary" href="${escapeHtml(session.session_link)}" target="_blank" rel="noopener" title="Åbn sessionslink"><i class="bi bi-display me-1"></i>Åbn</a>`
: '';
const worklogButton = session.status === 'completed'
? `<button class="btn btn-sm btn-outline-secondary" onclick="showWorklogSuggestion(${session.id})">Vis forslag</button>`
? `<button class="btn btn-sm btn-outline-secondary" onclick="showWorklogSuggestion(${Number(session.id)})" title="Vis forslag til tidsregistrering">Tidsforslag</button>`
: '';
return `
<div class="col-12">
<div class="session-card">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="badge ${badgeClass}">${escapeHtml(session.status || 'ukendt')}</span>
<strong>Session #${session.id}</strong>
</div>
<div class="session-meta">Start: ${startedAt} | Slut: ${endedAt} | Varighed: ${duration}</div>
${session.sag_id ? `<div class="session-meta">Sag ID: ${session.sag_id}</div>` : ''}
</div>
<div class="d-flex gap-2">
${linkButton}
${worklogButton}
</div>
</div>
</div>
</div>
`;
return `<tr>
<td class="text-nowrap">${escapeHtml(startedAt)}<div class="small text-muted">Session #${Number(session.id)}</div></td>
<td class="text-nowrap">${escapeHtml(endedAt)}</td>
<td class="text-nowrap fw-semibold">${escapeHtml(duration)}</td>
<td style="min-width:180px;">${caseLink}<button type="button" class="btn btn-sm btn-link p-0 d-block" onclick="openRemoteCasePicker(${Number(session.id)})">${session.sag_id ? 'Skift sag' : 'Tilknyt sag'}</button></td>
<td><span class="badge ${badgeClass}">${escapeHtml(statusLabels[session.status] || session.status || 'Ukendt')}</span></td>
<td><div class="d-flex gap-1 justify-content-end">${linkButton}${worklogButton || (!linkButton ? '<span class="text-muted">—</span>' : '')}</div></td>
</tr>`;
}).join('');
container.innerHTML = `<div class="col-12"><div class="table-responsive border rounded">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Start</th><th>Slut</th><th>Varighed</th><th>Sag</th><th>Status</th><th class="text-end">Handling</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div></div>`;
}
function openRemoteCasePicker(sessionId) {
if (remoteCaseSaving) return;
remoteCaseSessionId = sessionId;
clearTimeout(remoteCaseTimer);
document.getElementById('remoteCaseTitle').textContent = `Tilknyt sag · session #${sessionId}`;
document.getElementById('remoteCaseSearch').value = '';
document.getElementById('remoteCaseError').classList.add('d-none');
const modalElement = document.getElementById('remoteCaseModal');
modalElement.addEventListener('shown.bs.modal', () => document.getElementById('remoteCaseSearch').focus(), { once: true });
bootstrap.Modal.getOrCreateInstance(modalElement).show();
searchRemoteSessionCases();
}
function scheduleRemoteCaseSearch() {
clearTimeout(remoteCaseTimer);
remoteCaseSearchVersion++;
remoteCaseTimer = setTimeout(searchRemoteSessionCases, 220);
}
async function searchRemoteSessionCases() {
const version = ++remoteCaseSearchVersion;
const session = remoteSessionRows.find(row => Number(row.id) === Number(remoteCaseSessionId));
const host = document.getElementById('remoteCaseResults');
if (!session?.customer_id) {
host.innerHTML = '<div class="text-muted p-2">Tilknyt først et firma til sessionen via AnyDesk-sessioner.</div>';
return;
}
host.innerHTML = '<div class="text-muted p-2">Henter sager…</div>';
const params = new URLSearchParams({ customer_id: session.customer_id, limit: '8' });
const query = document.getElementById('remoteCaseSearch').value.trim().replace(/^#/, '');
if (query) params.set('q', query);
try {
const response = await fetch(`/api/v1/sag?${params}`);
if (!response.ok) throw new Error('Kunne ikke hente sager');
const data = await response.json();
if (version !== remoteCaseSearchVersion) return;
const cases = data.sager || (Array.isArray(data) ? data : []);
host.innerHTML = cases.length ? cases.map(item => `<button type="button" data-remote-case class="list-group-item list-group-item-action" onclick="linkRemoteSessionCase(${Number(item.id)})">
<div class="d-flex justify-content-between gap-2"><strong>#${Number(item.id)}</strong><span class="small text-muted">${escapeHtml(item.status || '')}</span></div>
<div>${escapeHtml(item.titel || item.title || '(Ingen titel)')}</div>
</button>`).join('') : '<div class="text-muted p-2">Ingen sager fundet. Prøv et andet sagsnummer eller søgeord.</div>';
} catch (error) {
if (version === remoteCaseSearchVersion) host.innerHTML = `<div class="text-danger p-2">${escapeHtml(error.message)}</div>`;
}
}
async function linkRemoteSessionCase(caseId) {
if (remoteCaseSaving) return;
remoteCaseSaving = true;
const sessionId = remoteCaseSessionId;
const errorBox = document.getElementById('remoteCaseError');
errorBox.classList.add('d-none');
document.querySelectorAll('[data-remote-case]').forEach(button => button.disabled = true);
try {
const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/link?sag_id=${caseId}&require_same_customer=true`, { method: 'PATCH' });
if (!response.ok) {
const error = await response.json();
throw new Error(typeof error.detail === 'string' ? error.detail : 'Kunne ikke tilknytte sagen');
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('remoteCaseModal')).hide();
await loadRemoteSessions();
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove('d-none');
} finally {
remoteCaseSaving = false;
document.querySelectorAll('[data-remote-case]').forEach(button => button.disabled = false);
}
}
async function startRemoteSession() {

199
app/core/case_visibility.py Normal file
View File

@ -0,0 +1,199 @@
"""Shared case confidentiality policy for HTML, APIs, feeds and work queues."""
from contextvars import ContextVar
import json
import re
from fastapi import HTTPException
from starlette.responses import JSONResponse
from psycopg2.errors import DeadlockDetected,SerializationFailure
from app.core.database import execute_query, execute_query_single
_scope = ContextVar('case_visibility_scope', default=None)
_DROP = object()
def hidden_case_ids(user):
if user.get('is_superadmin'):
return set()
current = _scope.get()
if current is not None and current[0] == user.get('id'):
return current[1]
if 'permissions' in user:
readable=bool(set(user.get('permissions') or []) & {'cases.view','tickets.view'})
else:
row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM users WHERE user_id=%s AND is_superadmin)
OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('cases.view','tickets.view')) AS allowed''',
(user.get('id'),user.get('id')))
readable=bool(row and row['allowed'])
if not readable:
return {r['id'] for r in execute_query('SELECT id FROM sag_sager') or []}
exists = execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present")
if not exists or not exists['present']:
return set()
return {r['sag_id'] for r in execute_query('''SELECT v.sag_id FROM sag_case_visibility v
WHERE v.restricted AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=v.sag_id AND a.user_id=%s)
AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id
WHERE a.sag_id=v.sag_id AND ug.user_id=%s)''',(user.get('id'),user.get('id'))) or []}
def require_case(user,sag_id):
if sag_id in hidden_case_ids(user):
# 404 avoids confirming whether a confidential case exists.
raise HTTPException(404,'Sagen findes ikke')
def notification_recipients(sag_id,user_ids):
exists=execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present")
if not exists or not exists['present']:
return user_ids,False
users=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND
(u.is_superadmin OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=u.user_id AND p.code IN ('cases.view','tickets.view')))''',(user_ids,)) or []
user_ids=[r['user_id'] for r in users]
private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(sag_id,))
if not private or not private['restricted']:
return user_ids,False
allowed=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND
(u.is_superadmin OR EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=%s AND a.user_id=u.user_id)
OR EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id
WHERE a.sag_id=%s AND ug.user_id=u.user_id))''',(user_ids,sag_id,sag_id)) or []
return [r['user_id'] for r in allowed],True
def linked_ids(value,case_list=False):
result = set()
if not isinstance(value,dict):
return result
for key in ('sag_id','hub_case_id','kilde_sag_id','målsag_id','parent_sag_id'):
if isinstance(value.get(key),int):
result.add(value[key])
if value.get('reference_type')=='case' and isinstance(value.get('reference_id'),int):
result.add(value['reference_id'])
if 'titel' in value and isinstance(value.get('case_id'),int):
result.add(value['case_id'])
elif (case_list or 'titel' in value or value.get('entity_type')=='case') and isinstance(value.get('id'),int):
result.add(value['id'])
if value.get('type')=='relation' and isinstance(value.get('case_id'),int):
result.add(value['case_id'])
for key in ('url','action','href','download_url','link','case_url','action_url','context_path'):
if isinstance(value.get(key),str):
result.update(int(s) for s in re.findall(r'/sag/(\d+)(?:/|\?|$)',value[key]))
if isinstance(value.get('extendedProps'),dict):
result.update(linked_ids(value['extendedProps']))
for key in ('case','linked_case','parent_case'):
if isinstance(value.get(key),dict):
result.update(linked_ids(value[key]))
return result
def filter_data(value,hidden,context=''):
"""Drop confidential records; calendar reservations retain only busy intervals."""
if not hidden:
return value
if isinstance(value,list):
result=[]
for item in value:
filtered=filter_data(item,hidden,context)
if filtered is not _DROP:
result.append(filtered)
return result
if not isinstance(value,dict):
return value
case_list='cases' in context or context in {'/api/v1/sag','/api/v1/planner/cases','urgent','unassigned','today_tasks'}
if linked_ids(value,case_list) & hidden:
start=value.get('starts_at') or value.get('start')
end=value.get('ends_at') or value.get('end')
if start and end:
safe={k:value[k] for k in ('starts_at','ends_at','start','end','user_id') if k in value}
safe.update(title='Optaget',case_title='Optaget',customer_name='',private=True,locked=True,editable=False,conflict_status='none')
safe['id']='busy-'+str(value.get('id','reservation'))
return safe
return _DROP
result={}
for key,item in value.items():
filtered=filter_data(item,hidden,key if key not in {'items','list'} else context)
if filtered is not _DROP:
result[key]=filtered
return result
def requested_case_ids(value):
result=set()
if isinstance(value,list):
for item in value:
result.update(requested_case_ids(item))
elif isinstance(value,dict):
if value.get('type')=='relation':
raw=value.get('case_id') or value.get('caseId') or value.get('target_id')
try:
result.add(int(str(raw).lstrip('#')))
except (ValueError,TypeError):
pass
for key,item in value.items():
if key in {'sag_id','hub_case_id','kilde_sag_id','målsag_id'} and isinstance(item,int):
result.add(item)
elif key=='sag_ids' and isinstance(item,list):
result.update(i for i in item if isinstance(i,int))
elif isinstance(item,(dict,list)):
result.update(requested_case_ids(item))
return result
def scrub_absence_time(value,user_id,can_see_reason):
if isinstance(value,list):
return [scrub_absence_time(item,user_id,can_see_reason) for item in value]
if not isinstance(value,dict):
return value
result={k:scrub_absence_time(v,user_id,can_see_reason) for k,v in value.items()}
owner=value.get('medarbejder_id',value.get('user_id'))
if value.get('planner_absence_id') and owner!=user_id and not can_see_reason:
for key in ('planner_activity_code','planner_absence_id'):
if key in result:
result[key]=None
if 'description' in result:
result['description']='Fravær'
return result
async def protect_request(request,call_next,user):
hidden=hidden_case_ids(user)
token=_scope.set((user.get('id'),hidden))
try:
request.state.case_superadmin=bool(user.get('is_superadmin'))
refs={int(s) for s in re.findall(r'/(?:sag|planner/cases)/(\d+)(?:/|$)',request.url.path)}
for key in ('sag_id','kilde_sag_id','målsag_id'):
raw=request.query_params.get(key)
if raw and raw.isdigit():
refs.add(int(raw))
if hidden and request.method in {'POST','PUT','PATCH','DELETE'} and 'application/json' in request.headers.get('content-type',''):
try:
refs.update(requested_case_ids(await request.json()))
except (ValueError,UnicodeError):
pass # Normal endpoint validation handles malformed JSON.
if refs & hidden:
return JSONResponse({'detail':'Sagen findes ikke'},status_code=404)
try:
response=await call_next(request)
except (DeadlockDetected,SerializationFailure):
if request.url.path.startswith('/api/v1/planner'):
return JSONResponse({'detail':'Planlægningen blev ændret samtidig. Genindlæs og prøv igen.'},status_code=409)
raise
check_absence=request.url.path.startswith('/api/v1/timetracking')
if (hidden or check_absence) and request.url.path.startswith('/api/') and 'application/json' in response.headers.get('content-type',''):
body=b''.join([chunk async for chunk in response.body_iterator])
data=json.loads(body)
filtered=filter_data(data,hidden,request.url.path.rstrip('/'))
if check_absence and b'planner_absence_id' in body:
allowed=bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_reason'})
if not allowed and 'permissions' not in user:
row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('planner.admin','planner.absence_reason')) AS allowed''',(user.get('id'),))
allowed=bool(row and row['allowed'])
filtered=scrub_absence_time(filtered,user.get('id'),allowed)
result=JSONResponse(None if filtered is _DROP else filtered,status_code=response.status_code,background=response.background)
result.raw_headers += [(k,v) for k,v in response.raw_headers if k.lower() not in {b'content-length',b'content-type'}]
return result
return response
finally:
_scope.reset(token)

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.core.database import execute_query, execute_query_single
from typing import Dict, Any, List
import logging
@ -171,7 +171,7 @@ async def global_search(q: str):
@router.get("/search/sag", response_model=List[Dict[str, Any]])
async def search_sag(q: str):
async def search_sag(q: str, request: Request):
"""
Search for cases (sager) with customer information
"""
@ -181,6 +181,9 @@ async def search_sag(q: str):
search_term = f"%{q}%"
try:
from app.core.case_visibility import hidden_case_ids
hidden=hidden_case_ids({'id':getattr(request.state,'user_id',None),
'is_superadmin':getattr(request.state,'case_superadmin',False)})
# Search cases with customer names
sager = execute_query("""
SELECT
@ -233,9 +236,10 @@ async def search_sag(q: str):
AND b.word ILIKE %s
)
)
AND NOT(s.id=ANY(%s))
ORDER BY s.created_at DESC
LIMIT 20
""", (search_term, search_term, search_term, search_term, search_term, search_term, search_term))
""", (search_term, search_term, search_term, search_term, search_term, search_term, search_term,list(hidden)))
return sager or []
except Exception as e:

View File

@ -198,19 +198,19 @@
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-xl-2">
<div class="also-kpi">
<div class="label">Omsætning</div>
<div class="label">Omsætning · denne måned</div>
<div class="value" id="kpiRevenue">0 kr.</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="also-kpi">
<div class="label">Kost</div>
<div class="label">Kost · denne måned</div>
<div class="value" id="kpiCost">0 kr.</div>
</div>
</div>
<div class="col-6 col-xl-2">
<div class="also-kpi">
<div class="label">Margin</div>
<div class="label">Margin · denne måned</div>
<div class="value" id="kpiMargin">0 kr.</div>
</div>
</div>
@ -368,7 +368,7 @@
<div class="also-card card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0">Queue Snapshot</h5>
<h5 class="mb-0">Faktureringslinjer</h5>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="queueStatusFilter" style="width: auto;">
<option value="">Alle statusser</option>
@ -395,7 +395,7 @@
<th>Beløb</th>
<th>Kost</th>
<th>Margin</th>
<th>Draft</th>
<th>Ordrekladde</th>
</tr>
</thead>
<tbody id="queueBody">
@ -467,7 +467,11 @@
const statusBadge = (status) => {
const s = String(status || 'unknown').toLowerCase();
return `<span class="status-pill status-${s}">${s.replaceAll('_', ' ')}</span>`;
const labels = { new: 'Ny', matching_customers: 'Matcher kunder', matching_products: 'Matcher produkter',
ready_for_approval: 'Klar til godkendelse', approved: 'Godkendt', invoiced: 'Faktureret',
error: 'Fejl', completed: 'Afsluttet', processing: 'Behandles', partial: 'Delvist afsluttet',
failed: 'Mislykket', deleted: 'Slettet', unknown: 'Ukendt' };
return `<span class="status-pill status-${escapeHtml(s)}">${escapeHtml(labels[s] || s.replaceAll('_', ' '))}</span>`;
};
const formatCurrency = (value) => {
@ -521,9 +525,9 @@
`Linjer modtaget: ${formatNumber(payload.import_result?.received || 0)}`,
`Linjer indsat: ${formatNumber(payload.import_result?.inserted || 0)}`,
`Dubletter: ${formatNumber(payload.import_result?.duplicates || 0)}`,
`Klar til approval efter validering: ${formatNumber(payload.validation_result?.ready_for_approval || 0)}`,
`Klar til godkendelse efter validering: ${formatNumber(payload.validation_result?.ready_for_approval || 0)}`,
`Oprettede ordrekladder: ${formatNumber(payload.approval_result?.created_drafts || 0)}`,
`Approved linjer: ${formatNumber(payload.queue_totals?.approved_lines || 0)}`,
`Godkendte linjer: ${formatNumber(payload.queue_totals?.approved_lines || 0)}`,
`Fejllinjer: ${formatNumber(payload.queue_totals?.error_lines || 0)}`
];
el('uploadResult').textContent = lines.join('\n');
@ -593,10 +597,10 @@
el('differencesBody').innerHTML = rows.length
? rows.map(row => `
<tr>
<td>${row.customer_name || '-'}</td>
<td>${escapeHtml(row.customer_name || '-')}</td>
<td>
<div>${row.product_name || '-'}</div>
<div class="small text-muted mono">${row.material_number || '-'}</div>
<div>${escapeHtml(row.product_name || '-')}</div>
<div class="small text-muted mono">${escapeHtml(row.material_number || '-')}</div>
</td>
<td>${formatNumber(row.previous_month_qty)}</td>
<td>${formatNumber(row.current_month_qty)}</td>
@ -617,7 +621,7 @@
<tr data-job-row="${row.id}" class="${state.selectedJobId === row.id ? 'active-row' : ''}">
<td class="fw-semibold">#${row.id}</td>
<td>${statusBadge(row.status)}</td>
<td>${row.file_name || '-'}</td>
<td>${escapeHtml(row.file_name || '-')}</td>
<td>${formatDateTime(row.imported_at)}</td>
<td>${formatNumber(row.line_count)}</td>
</tr>
@ -877,8 +881,8 @@
el('uploadResult').textContent = [
`Linje #${result.line_id} mappet til ${result.customer_name} (#${result.customer_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Approved linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Klar efter validering: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Godkendte linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n');
@ -927,8 +931,8 @@
el('uploadResult').textContent = [
`Linje #${result.line_id} mappet til ${result.product_name} (#${result.product_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Approved linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Klar efter validering: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Godkendte linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n');
@ -967,8 +971,8 @@
`Oprettede company mappings: ${formatNumber(result.customer_mappings_created || 0)}`,
`Oprettede product mappings: ${formatNumber(result.product_mappings_created || 0)}`,
`Oprettede varer: ${formatNumber(result.products_created || 0)}`,
`Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Approved linjer: ${formatNumber(result.queue_totals?.approved_lines || 0)}`,
`Klar efter validering: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Godkendte linjer: ${formatNumber(result.queue_totals?.approved_lines || 0)}`,
`Fejllinjer: ${formatNumber(result.queue_totals?.error_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
];
@ -1065,14 +1069,14 @@
<tr>
<td class="mono">#${row.id}</td>
<td>${statusBadge(row.queue_status)}</td>
<td>${row.matched_customer_id || row.company || '-'}</td>
<td>${escapeHtml(row.matched_customer_name || row.company || 'Ukendt kunde')}</td>
<td>
<div>${row.product_name || '-'}</div>
<div class="small text-muted mono">${row.material_number || '-'}</div>
<div>${escapeHtml(row.product_name || '-')}</div>
<div class="small text-muted mono">${escapeHtml(row.material_number || '-')}</div>
</td>
<td>${formatCurrency(row.total_price || 0)}</td>
<td>${formatCurrency(row.effective_cost_amount || row.cost_amount || 0)}</td>
<td>${formatCurrency(row.effective_margin_amount || (Number(row.total_price || 0) - Number(row.effective_cost_amount || row.cost_amount || 0)))}</td>
<td>${formatCurrency(row.total_price ?? row.sales_price ?? 0)}</td>
<td>${formatCurrency(row.effective_cost_amount ?? row.cost_amount ?? 0)}</td>
<td>${formatCurrency(row.effective_margin_amount ?? (Number(row.total_price ?? row.sales_price ?? 0) - Number(row.effective_cost_amount ?? row.cost_amount ?? 0)))}</td>
<td>${row.order_draft_id ? `<a href="/ordre/${row.order_draft_id}" class="mono">#${row.order_draft_id}</a>` : '-'}</td>
</tr>
`).join('')
@ -1082,7 +1086,7 @@
const uploadZip = async () => {
const file = el('alsoZipFile').files?.[0];
if (!file) {
alert('Vælg en ZIP-fil først.');
alert('Vælg en Excel-fil (.xlsx) eller ZIP-fil først.');
return;
}
const formData = new FormData();

View File

@ -31,6 +31,8 @@ async def check_reminders():
logger.info("🔔 Checking for pending reminders...")
from app.modules.sag.backend.reminders import process_task_list_rules
await process_task_list_rules()
from app.modules.planner.backend.checks import run_planning_checks
run_planning_checks()
# Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue()

View File

@ -3,7 +3,7 @@ Pydantic Models and Schemas
"""
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, List
from datetime import datetime, date
@ -268,6 +268,11 @@ class AnyDeskSession(BaseModel):
created_by_user_id: Optional[int] = None
created_at: str
updated_at: Optional[str] = None
@field_validator('started_at', 'ended_at', 'created_at', 'updated_at', mode='before')
@classmethod
def serialize_database_timestamps(cls, value):
return value.isoformat() if isinstance(value, datetime) else value
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,96 @@
"""Keep ALSO's approved period amount authoritative on order lines."""
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from datetime import datetime, timedelta
import calendar
import re
from fastapi import HTTPException
def _number(value):
try:
result = Decimal(str(value))
if not result.is_finite():
raise InvalidOperation
return result
except (InvalidOperation, TypeError, ValueError):
raise HTTPException(409, 'ALSO-linjen mangler et gyldigt godkendt periodebeløb')
def period_amount_line(line):
"""Return a copy; never rewrite saved history or normalize other sources."""
if line.get('source_type') != 'also_cloud':
return dict(line)
amount = _number(line.get('amount'))
if amount < 0 or amount != amount.quantize(Decimal('.01'), rounding=ROUND_HALF_UP):
raise HTTPException(409, 'ALSO-periodebeløbet kræver afklaring før eksport')
if _number(line.get('discount_percentage', 0)) != 0:
raise HTTPException(409, 'ALSO-periodebeløbet må ikke kombineres med linjerabat')
result = dict(line)
meta = dict(line.get('meta') or {})
if meta.get('also_period_amount_version') != 1:
quantity = _number(line.get('quantity'))
monthly_price = _number(line.get('unit_price'))
if quantity <= 0 or monthly_price < 0:
raise HTTPException(409, 'ALSO-linjen mangler gyldigt licensantal eller månedspris')
meta.update(also_license_quantity=str(quantity), also_monthly_unit_price=str(monthly_price),
also_period_amount_version=1)
quantity_text = format(quantity, 'f').rstrip('0').rstrip('.') if '.' in str(quantity) else str(quantity)
price_text = f'{monthly_price:.2f}'.replace('.', ',')
license_text = 'licens' if quantity == 1 else 'licenser'
description = result.get('description') or 'Cloud abonnement'
title, separator, rest = description.partition('\n')
currency_text = 'kr.' if result.get('currency', 'DKK') == 'DKK' else result['currency']
explanation = f'{quantity_text} {license_text} á {price_text} {currency_text}/måned.'
interval = meta.get('billing_period')
period_text = _period_text(interval)
result['description'] = title + '\n' + explanation + (
' ' + period_text if period_text else ('\n' + rest if separator else ''))
result['description'] = re.sub(r'\s*Reference:\s*[^\n]*', '', result.get('description', ''), flags=re.IGNORECASE).strip()
result.update(quantity=1.0, unit_price=float(amount), amount=float(amount),
discount_percentage=0.0, meta=meta)
return result
def _period_text(interval):
"""ALSO Marketplace intervals have an exclusive end date."""
match = re.fullmatch(r'\s*(\d{2})[./-](\d{2})[./-](\d{4})\s*-\s*(\d{2})[./-](\d{2})[./-](\d{4})\s*', str(interval or ''))
if not match:
return None
try:
day, month, year, end_day, end_month, end_year = map(int, match.groups())
start = datetime(year, month, day).date()
end = datetime(end_year, end_month, end_day).date()
except ValueError:
raise HTTPException(409, 'ALSO-linjen har en ugyldig periode')
if end <= start:
raise HTTPException(409, 'ALSO-periodens slutdato skal være efter startdatoen')
last = end - timedelta(days=1)
start_text = start.strftime('%d.%m.') if start.year == last.year else start.strftime('%d.%m.%Y')
text = f'Periode: {start_text}–{last:%d.%m.%Y}, {(end-start).days}'
if (start.year, start.month) == (last.year, last.month):
text += f' af {calendar.monthrange(start.year, start.month)[1]}'
return text + ' dage.'
def check_period_amounts(lines, saved_lines):
"""Reject stale browsers and altered amounts before either export backend runs."""
saved = {str(line.get('line_key')): line for line in saved_lines}
seen = set()
for line in lines:
old = saved.get(str(line.get('line_key')))
if line.get('source_type') != 'also_cloud' and (not old or old.get('source_type') != 'also_cloud'):
continue
if not old or old.get('source_type') != 'also_cloud' or line.get('source_type') != 'also_cloud':
raise HTTPException(409, 'ALSO-eksport kræver den gemte godkendte ordrekladde')
key = str(line.get('line_key'))
if key in seen or line.get('source_id') != old.get('source_id'):
raise HTTPException(409, 'ALSO-kildelinjen mangler entydig identitet eller er valgt flere gange')
seen.add(key)
expected = period_amount_line(old)
if (line.get('product_id') != old.get('product_id')
or line.get('description') != expected.get('description')
or line.get('currency', 'DKK') != old.get('currency', 'DKK')
or any(_number(line.get(key, 0)) != _number(expected[key])
for key in ('quantity', 'unit_price', 'discount_percentage'))):
raise HTTPException(409, 'ALSO-linjen matcher ikke det godkendte periodebeløb. Genindlæs kladden og kontrollér linjerne før eksport.')

View File

@ -17,6 +17,7 @@ from fastapi import HTTPException
import httpx
from app.core.config import settings
from app.modules.also.backend.billing import period_amount_line
from app.core.database import execute_query, execute_query_single, execute_update, get_db_connection, release_db_connection, table_has_column
from psycopg2.extras import RealDictCursor
from app.modules.also.models.schemas import (
@ -1704,9 +1705,11 @@ class AlsoService:
f"""
SELECT
l.*,
c.name AS matched_customer_name,
{ALSO_EFFECTIVE_COST_SQL} AS effective_cost_amount,
COALESCE(l.total_price, l.sales_price, 0) - ({ALSO_EFFECTIVE_COST_SQL}) AS effective_margin_amount
FROM also_import_lines l
LEFT JOIN customers c ON c.id = l.matched_customer_id
LEFT JOIN products p ON p.id = l.matched_product_id
WHERE l.queue_status = %s
ORDER BY l.id DESC
@ -1719,9 +1722,11 @@ class AlsoService:
f"""
SELECT
l.*,
c.name AS matched_customer_name,
{ALSO_EFFECTIVE_COST_SQL} AS effective_cost_amount,
COALESCE(l.total_price, l.sales_price, 0) - ({ALSO_EFFECTIVE_COST_SQL}) AS effective_margin_amount
FROM also_import_lines l
LEFT JOIN customers c ON c.id = l.matched_customer_id
LEFT JOIN products p ON p.id = l.matched_product_id
ORDER BY l.id DESC
LIMIT %s
@ -2020,28 +2025,37 @@ class AlsoService:
draft_lines: List[Dict[str, Any]] = []
for line in group_lines:
quantity = _to_decimal(line.get("billable_parameters"), Decimal("1"))
if quantity <= 0:
quantity = Decimal("1")
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
quantity = line.get("billable_parameters")
unit_price = line.get("unit_price")
raw_line = line.get('raw_line_json') or {}
if isinstance(raw_line, str):
raw_line = json.loads(raw_line)
raw_fields = {_normalize_header(key): value for key, value in raw_line.items()}
if raw_fields.get('charge') not in (None, ''):
# Charge is the period total, not a monthly price per licence.
unit_price = next((
_parse_decimal_candidate(raw_fields[key])
for key in self.HEADER_ALIASES['unit_price']
if key != 'charge' and raw_fields.get(key) not in (None, '')
), None)
amount = line.get("total_price")
description = line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement"
charge_period = _format_charge_period(line.get("charge_interval"))
if charge_period:
description = f"{description}\nPeriode: {charge_period}"
draft_lines.append(
{
period_amount_line({
"line_key": f"also:{line['id']}",
"source_type": "also_cloud",
"source_id": int(line["id"]),
"reference_id": int(line["import_job_id"]),
"description": description,
"quantity": float(quantity),
"quantity": quantity,
"unit": "stk",
"unit_price": float(unit_price),
"unit_price": unit_price,
"discount_percentage": 0.0,
"amount": float(amount),
"amount": amount,
"currency": currency,
"status": "approved",
"line_date": str(line.get("billing_start")) if line.get("billing_start") else None,
@ -2055,7 +2069,7 @@ class AlsoService:
"also_import_job_id": int(line.get("import_job_id")),
"billing_period": charge_period,
},
}
})
)
aggregate_key = f"also-cloud-{customer_id}-{period_key}"

View File

@ -1282,7 +1282,8 @@ def build_bottom_bar_state(
"context_actions": context_actions,
}
return {
from app.core.case_visibility import hidden_case_ids,filter_data
return filter_data({
"enabled": True,
"sections": sections,
"status": status,
@ -1290,4 +1291,4 @@ def build_bottom_bar_state(
"own_timers": own_timers,
"recent_cases": recent_cases,
"notifications": notifications,
}
},hidden_case_ids({'id':user_id,'is_superadmin':force_boss_access}))

View File

@ -1,13 +1,13 @@
import logging
import hmac
import hashlib
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from app.core.auth_dependencies import get_current_user
from app.core.config import settings
from app.core.database import execute_query
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
router = APIRouter()
@ -136,6 +136,8 @@ def _escape_ical(value: str) -> str:
def _format_ical_dt(value: datetime) -> str:
if value.tzinfo is not None:
return value.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
return value.strftime("%Y%m%dT%H%M%S")
@ -160,6 +162,8 @@ def _get_calendar_events(
"obs",
"quick_planned",
"quick_registered",
"planner_booking",
"absence",
}
requested_types = {
t.strip() for t in (types.split(",") if types else []) if t.strip()
@ -354,11 +358,59 @@ def _get_calendar_events(
event['textColor'] = '#ffffff'
events.append(event)
if not normalized_types or normalized_types & {'planner_booking','absence'}:
events.extend(_planning_feed_events(request,start_dt,end_dt,resolved_user_id,only_mine,customer_id,normalized_types))
events.sort(key=lambda item: item.get("start") or "")
return events
def _planning_feed_events(request,start,end,target,only_mine,customer_id,kinds):
"""Read existing reservations into the legacy feed, without copying them."""
present=execute_query_single("SELECT to_regclass('planner_events') IS NOT NULL AS ready")
if not present or not present['ready']:
return []
from app.core.auth_service import AuthService
from app.core.case_visibility import filter_data,hidden_case_ids
from app.modules.planner.backend.router import _can_view,_can_view_target,_aware
from app.modules.planner.backend.calendar import visible_absence
viewer=getattr(request.state,'user_id',None) or target
row=execute_query_single('SELECT user_id,is_superadmin FROM users WHERE user_id=%s AND is_active',(viewer,))
if not row:
return []
actor={'id':row['user_id'],'is_superadmin':row['is_superadmin'],'permissions':AuthService.get_user_permissions(row['user_id'])}
if not _can_view(actor):
return []
if only_mine:
ids=[target or actor['id']]
if not _can_view_target(actor,ids[0]):
raise HTTPException(403,'Du har ikke adgang til denne medarbejders plan')
else:
ids=[u['user_id'] for u in execute_query('SELECT user_id FROM users WHERE is_active') or [] if _can_view_target(actor,u['user_id'])]
result=[]
if not kinds or 'planner_booking' in kinds:
rows=execute_query('''SELECT a.id,a.event_id,a.sag_id,a.user_id,a.starts_at,a.ends_at,e.title
FROM planner_allocations a JOIN planner_events e ON e.id=a.event_id LEFT JOIN sag_sager s ON s.id=a.sag_id
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s
AND (%s IS NULL OR s.customer_id=%s) ORDER BY a.id''',(ids,_aware(end),_aware(start),customer_id,customer_id)) or []
seen=set()
for item in rows:
if item['event_id'] in seen:
continue
seen.add(item['event_id'])
result.append({'id':f'planner:{item["event_id"]}','title':item['title'],'start':item['starts_at'].isoformat(),
'end':item['ends_at'].isoformat(),'sag_id':item['sag_id'],'event_type':'planner_booking',
'url':f'/planning?view=calendar&event_id={item["event_id"]}'})
if (not kinds or 'absence' in kinds) and not customer_id:
labels={r['code']:r['label'] for r in execute_query('SELECT code,label FROM planner_activity_types') or []}
for item in execute_query('SELECT * FROM planner_absences WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s',(ids,_aware(end),_aware(start))) or []:
safe=visible_absence(item,actor)
result.append({'id':f'absence:{item["id"]}','title':'Fravær' if safe['kind']=='unavailable' else
labels.get(safe['kind'],'Fravær'),
'start':item['starts_at'].isoformat(),'end':item['ends_at'].isoformat(),'event_type':'absence','url':'/planning?view=calendar'})
return filter_data(result,hidden_case_ids(actor))
@router.get("/calendar/events")
async def get_calendar_events(
request: Request,
@ -418,6 +470,9 @@ async def get_calendar_ical(
types=types,
)
from app.core.case_visibility import hidden_case_ids,filter_data
events=filter_data(events,hidden_case_ids({'id':getattr(request.state,'user_id',None),
'is_superadmin':getattr(request.state,'case_superadmin',False)}))
return _build_ical_response(events, now)
@ -467,4 +522,6 @@ async def get_calendar_ical_feed(
customer_id=customer_id,
types=types,
)
from app.core.case_visibility import hidden_case_ids,filter_data
events=filter_data(events,hidden_case_ids({'id':user_id}))
return _build_ical_response(events, now)

View File

@ -0,0 +1,284 @@
"""Reviewed hardware intake from invoice lines, product codes, or manual entry."""
import copy
import hashlib
import json
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Literal, Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import atomic_transaction, execute_query
router = APIRouter(prefix='/hardware-intake')
def _one(sql, params=()):
rows = execute_query(sql, params)
return rows[0] if rows else None
def _decimal(value):
try:
number = Decimal(str(value))
if number.is_finite():
return number
except (InvalidOperation, ValueError, TypeError):
pass
raise HTTPException(422, 'Antal og beløb skal være gyldige tal')
def _lines(raw_lines, use_line_numbers=False):
result = []
seen = set()
for index, line in enumerate(raw_lines or [], 1):
key = str((line.get('line_number') or index) if use_line_numbers else (line.get('id') or line.get('line_number') or index))
if key in seen:
raise HTTPException(422, 'Fakturaen har tvetydige linjenumre; kontrollér fakturagrundlaget')
seen.add(key)
qty = line.get('quantity')
price = line.get('unit_price')
result.append(dict(key=key, source_line_id=line.get('id'), description=str(line.get('description') or line.get('name') or ''),
sku=line.get('sku') or line.get('item_number'), quantity=str(qty) if qty is not None else None,
unit_price=str(price) if price is not None else None))
return result
def _store_preview(kind, source_id, header, lines):
payload = dict(header=header, lines=_lines(lines, use_line_numbers=kind == 'file' or str(header.get('allocation_source') or '').startswith('file:')))
preview_id = str(uuid4())
execute_query('''INSERT INTO hardware_intake_previews(id,source_kind,source_id,payload)
VALUES(%s,%s,%s,%s)''', (preview_id, kind, source_id, Json(payload, dumps=lambda value: json.dumps(value, default=str))))
taken = execute_query("SELECT id,hardware_specs->>'intake_unit_key' AS key FROM hardware_assets WHERE starts_with(hardware_specs->>'intake_unit_key',%s)", (header.get('allocation_source', f'{kind}:{source_id}') + ':',)) or []
created_units = {}
for row in taken:
key = row['key'].rsplit(':', 2)[-2]
created_units.setdefault(key, []).append(row['id'])
return dict(preview_id=preview_id, source_kind=kind, source_id=source_id, created_units=created_units, **payload)
def _invoice_preview(invoice_id):
invoice = _one('''SELECT id,invoice_number,vendor_name,invoice_date,currency,file_path,status,extraction_id
FROM supplier_invoices WHERE id=%s''', (invoice_id,))
if not invoice:
raise HTTPException(404, 'Leverandørfakturaen findes ikke')
if invoice['status'] in ('cancelled', 'rejected', 'credited'):
raise HTTPException(409, 'Afviste, annullerede eller krediterede fakturaer kan ikke bruges til ny hardware')
file_path = invoice.pop('file_path', None)
invoice['document_available'] = bool(file_path)
extraction_id = invoice.pop('extraction_id', None)
linked_file = _one('SELECT file_id FROM extractions WHERE extraction_id=%s', (extraction_id,)) if extraction_id else None
if not linked_file and file_path:
linked_file = _one('SELECT file_id FROM incoming_files WHERE file_path=%s ORDER BY file_id LIMIT 1', (file_path,))
if linked_file:
invoice['allocation_source'] = f"file:{linked_file['file_id']}"
invoice['document_available'] = True
lines = execute_query('SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id=%s ORDER BY line_number,id', (invoice_id,))
return _store_preview('invoice', invoice_id, invoice, lines)
async def _file_preview(file_id):
from app.services.ollama_service import ollama_service
from app.services.invoice2data_service import get_invoice2data_service
file = _one('SELECT file_id,filename,file_path FROM incoming_files WHERE file_id=%s', (file_id,))
if not file:
raise HTTPException(404, 'Fakturafilen findes ikke')
invoice = _one('''SELECT id FROM supplier_invoices WHERE file_path=%s
OR id IN (SELECT supplier_invoice_id FROM supplier_invoice_attachments WHERE file_path=%s)
OR extraction_id IN (SELECT extraction_id FROM extractions WHERE file_id=%s)
ORDER BY id LIMIT 1''', (file['file_path'], file['file_path'], file_id))
if invoice:
return _invoice_preview(invoice['id'])
extraction = _one('SELECT * FROM extractions WHERE file_id=%s ORDER BY created_at DESC LIMIT 1', (file_id,))
payload = {}
lines = []
if extraction:
raw = extraction.get('llm_response_json') or {}
payload = json.loads(raw) if isinstance(raw, str) else raw
if not isinstance(payload, dict):
payload = {}
lines = execute_query('SELECT * FROM extraction_lines WHERE extraction_id=%s ORDER BY line_number', (extraction['extraction_id'],)) or payload.get('lines') or []
if not lines:
path = Path(file['file_path'])
if not path.is_file():
raise HTTPException(404, 'Fakturafilen mangler på disken')
text = await ollama_service._extract_text_from_file(path)
if not text.strip():
raise HTTPException(422, 'Kunne ikke læse fakturaen. Prøv en tydeligere fil eller manuel oprettelse.')
payload = get_invoice2data_service().extract(text) or {}
if not payload.get('lines'):
payload = await ollama_service.extract_from_text(text)
lines = payload.get('lines') or []
header = {key: payload.get(key) or (extraction or {}).get(key) for key in
('invoice_number', 'vendor_name', 'invoice_date', 'currency')}
header['filename'] = file['filename']
header['document_available'] = True
return _store_preview('file', file_id, header, lines)
@router.get('/sources')
async def list_sources(q: str = Query('', max_length=200)):
term = '%' + q.strip() + '%'
invoices = execute_query('''SELECT id,invoice_number,vendor_name,invoice_date FROM supplier_invoices
WHERE status NOT IN ('cancelled','rejected','credited')
AND (invoice_number ILIKE %s OR COALESCE(vendor_name,'') ILIKE %s)
ORDER BY created_at DESC LIMIT 20''', (term, term)) or []
files = execute_query('''SELECT file_id,filename FROM incoming_files
WHERE filename ILIKE %s ORDER BY uploaded_at DESC LIMIT 20''', (term,)) or []
return {'sources': [dict(kind='invoice', id=row['id'], label=f"{row.get('vendor_name') or 'Leverandør'} · {row['invoice_number']}") for row in invoices]
+ [dict(kind='file', id=row['file_id'], label=row['filename']) for row in files]}
class SourceInput(BaseModel):
kind: Literal['invoice', 'file']
id: int = Field(gt=0)
@router.post('/preview')
async def preview_source(source: SourceInput):
return _invoice_preview(source.id) if source.kind == 'invoice' else await _file_preview(source.id)
@router.post('/upload')
async def upload_preview(file: UploadFile = File(...)):
from app.billing.backend.supplier_invoices import upload_supplier_invoice
suffix = Path(file.filename or '').suffix.lower()
if suffix not in ('.pdf', '.png', '.jpg', '.jpeg'):
raise HTTPException(422, 'Vælg en PDF, PNG eller JPG')
uploaded = await upload_supplier_invoice(file)
file_id = uploaded.get('file_id')
if not file_id:
raise HTTPException(422, 'Upload kunne ikke knyttes til en fakturafil')
return await _file_preview(file_id)
class IntakeInput(BaseModel):
request_id: UUID
mode: Literal['manual', 'barcode', 'invoice'] = 'manual'
hardware: dict
count: int = Field(default=1, ge=1, le=100)
serial_numbers: list[str] = Field(default_factory=list, max_length=100)
preview_id: Optional[UUID] = None
line_key: Optional[str] = None
code: Optional[str] = Field(default=None, max_length=200)
product_id: Optional[int] = Field(default=None, gt=0)
approved_unit_cost: Optional[Decimal] = Field(default=None, ge=0, allow_inf_nan=False)
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
@router.post('/create')
async def create_from_intake(request: IntakeInput):
from app.modules.hardware.backend.router import create_hardware
if not request.hardware.get('asset_type'):
raise HTTPException(422, 'Vælg hardwaretype før oprettelse')
if request.mode == 'barcode' and not request.code:
raise HTTPException(422, 'Slå en stregkode op, og vælg en vare før oprettelse')
if request.count > 1 and any(request.hardware.get(key) for key in ('internal_asset_id', 'customer_asset_id', 'anydesk_id', 'anydesk_link')):
raise HTTPException(422, 'Ved flere enheder skal individuelle asset-ID og AnyDesk-ID udfyldes på hver hardwarepost efter oprettelsen')
serials = [value.strip() for value in request.serial_numbers]
if not serials:
serials = [str(request.hardware.get('serial_number') or '').strip()] if request.count == 1 else [''] * request.count
if len(serials) != request.count:
raise HTTPException(422, 'Angiv ét serienummer pr. enhed, eller lad serienumrene være tomme')
normalized_serials = [value.replace(' ', '').replace('-', '').upper() for value in serials if value]
if len(set(normalized_serials)) != len(normalized_serials):
raise HTTPException(409, 'Samme serienummer må ikke bruges på flere enheder')
request_json = request.model_dump(mode='json')
fingerprint = hashlib.sha256(json.dumps(request_json, sort_keys=True).encode()).hexdigest()
with atomic_transaction():
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-request:' + str(request.request_id),))
previous = execute_query('''SELECT id,hardware_specs FROM hardware_assets
WHERE hardware_specs->>'intake_request_id'=%s ORDER BY id''', (str(request.request_id),)) or []
if previous:
if any(row['hardware_specs'].get('intake_request_hash') != fingerprint for row in previous):
raise HTTPException(409, 'Oprettelsen er allerede gennemført med andre data. Genindlæs formularen for en ny oprettelse.')
return {'hardware_ids': [row['id'] for row in previous], 'already_created': True}
source = dict(mode=request.mode, code=request.code)
unit_keys = [f"manual:{request.request_id}:{index}" for index in range(request.count)]
invoice_id = None
if request.mode == 'invoice':
preview = _one('''SELECT * FROM hardware_intake_previews WHERE id=%s
AND created_at > NOW()-INTERVAL '24 hours' ''', (str(request.preview_id),)) if request.preview_id else None
if not preview:
raise HTTPException(409, 'Vælg og gennemgå fakturalinjen igen; forhåndsvisningen mangler eller er udløbet')
line = next((line for line in preview['payload']['lines'] if line['key'] == request.line_key), None)
if not line:
raise HTTPException(422, 'Vælg en fakturalinje')
quantity = _decimal(line['quantity'])
if quantity <= 0 or quantity != int(quantity) or quantity > 10000:
raise HTTPException(422, 'Fakturalinjen skal have et positivt helt antal fysiske enheder')
if line['unit_price'] is not None and _decimal(line['unit_price']) < 0:
raise HTTPException(422, 'Kreditlinjer kan ikke oprette ny hardware')
key_prefix = preview['payload']['header'].get('allocation_source', f"{preview['source_kind']}:{preview['source_id']}") + f":{line['key']}:"
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-source:' + key_prefix,))
taken = execute_query("SELECT hardware_specs->>'intake_unit_key' AS key FROM hardware_assets WHERE starts_with(hardware_specs->>'intake_unit_key',%s)", (key_prefix,)) or []
occupied = {row['key'] for row in taken}
unit_keys = [key_prefix + str(index) for index in range(1, int(quantity)+1) if key_prefix + str(index) not in occupied][:request.count]
if len(unit_keys) != request.count:
raise HTTPException(409, 'Der er allerede oprettet hardware for disse enheder. Vælg færre enheder eller gennemgå eksisterende hardware.')
source = dict(mode='invoice', source_kind=preview['source_kind'], source_id=preview['source_id'],
line=copy.deepcopy(line), header=copy.deepcopy(preview['payload']['header']))
if preview['source_kind'] == 'invoice':
invoice_id = preview['source_id']
invoice = _one('SELECT id,status FROM supplier_invoices WHERE id=%s', (invoice_id,))
current_line = _one('SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id=%s AND id=%s', (invoice_id, line['source_line_id']))
current_snapshot = _lines([current_line], use_line_numbers=str(preview['payload']['header'].get('allocation_source') or '').startswith('file:'))[0] if current_line else None
if current_snapshot and current_line.get('line_number') is None:
current_snapshot['key'] = line['key']
if current_snapshot != line:
raise HTTPException(409, 'Fakturalinjen er ændret. Gennemgå fakturaen igen før oprettelse.')
if not invoice or invoice['status'] in ('cancelled', 'rejected', 'credited'):
raise HTTPException(409, 'Fakturaen er ikke længere tilgængelig som oprettelsesgrundlag')
elif not _one('SELECT file_id FROM incoming_files WHERE file_id=%s', (preview['source_id'],)):
raise HTTPException(409, 'Fakturafilen findes ikke længere')
source.update(approved_unit_cost=str(request.approved_unit_cost) if request.approved_unit_cost is not None else None,
approved_currency=request.currency)
if request.product_id:
product = _one('SELECT id,name,ean,sku_internal FROM products WHERE id=%s AND deleted_at IS NULL', (request.product_id,))
if not product:
raise HTTPException(409, 'Den valgte vare findes ikke længere')
source['product'] = dict(product)
for normalized_serial in sorted(normalized_serials):
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-serial:' + normalized_serial,))
created = []
for index, serial in enumerate(serials):
if serial:
duplicate = _one("""SELECT id FROM hardware_assets WHERE deleted_at IS NULL
AND UPPER(REPLACE(REPLACE(COALESCE(serial_number,''),' ',''),'-',''))=%s LIMIT 1""", (serial.replace(' ', '').replace('-', '').upper(),))
if duplicate:
raise HTTPException(409, f"Serienummeret findes allerede på hardware #{duplicate['id']}")
hardware = copy.deepcopy(request.hardware)
if hardware.get('hardware_specs') is not None and not isinstance(hardware['hardware_specs'], dict):
raise HTTPException(422, 'Hardwarespecifikationer skal være et objekt')
specs = dict(hardware.get('hardware_specs') or {})
specs.update(intake_request_id=str(request.request_id), intake_request_hash=fingerprint,
intake_unit_key=unit_keys[index], acquisition=source)
hardware.update(serial_number=serial or None, hardware_specs=specs)
asset = await create_hardware(hardware)
if invoice_id:
execute_query('''INSERT INTO supplier_invoice_relations(supplier_invoice_id,relation_type,relation_id,note)
VALUES(%s,'asset',%s,'Oprettet fra godkendt fakturalinje') ON CONFLICT DO NOTHING''', (invoice_id, asset['id']))
created.append(asset['id'])
return {'hardware_ids': created, 'already_created': False}
@router.get('/{hardware_id}/document')
async def acquisition_document(hardware_id: int):
asset = _one('SELECT hardware_specs FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,))
source = ((asset or {}).get('hardware_specs') or {}).get('acquisition') or {}
if source.get('source_kind') == 'invoice':
document = _one('''SELECT COALESCE(NULLIF(si.file_path,''),f.file_path) AS file_path
FROM supplier_invoices si LEFT JOIN extractions e ON e.extraction_id=si.extraction_id
LEFT JOIN incoming_files f ON f.file_id=e.file_id WHERE si.id=%s''', (source['source_id'],))
elif source.get('source_kind') == 'file':
document = _one('SELECT file_path FROM incoming_files WHERE file_id=%s', (source['source_id'],))
else:
document = None
path = Path(document['file_path']) if document and document.get('file_path') else None
if not path or not path.is_file():
raise HTTPException(404, 'Det oprindelige fakturabilag findes ikke')
return FileResponse(path, filename=path.name)

View File

@ -0,0 +1,127 @@
"""Read-only Gateway inventory lookup for reviewed asset creation."""
import asyncio
import json
from typing import Literal
from urllib.parse import quote
import aiohttp
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import execute_query
from app.core.config import settings
from app.products.backend.router import _apigw_base_url, _apigw_headers
router = APIRouter(prefix='/hardware-intake/inventory')
def inventory_item(record):
raw = record.get('json_data', record.get('hardware_data')) or {}
if isinstance(raw, str):
raw = json.loads(raw)
hardware = raw.get('hardware') or {}
return dict(id=record['id'], name=record.get('computer_name') or '',
brand=record.get('manufacturer') or hardware.get('manufacturer') or '',
model=record.get('model') or hardware.get('model') or '',
serial_number=record.get('serial_number') or '',
os=record.get('os_caption') or (raw.get('os') or {}).get('caption') or '',
cpu=record.get('cpu_name') or (hardware.get('cpu') or {}).get('name') or '',
ram_gb=record.get('ram_gb', hardware.get('ram_gb')),
last_seen=record.get('last_seen') or record.get('collected_at'),
hardware_specs={'gateway_inventory': {'id':record['id'], 'collected_at':record.get('last_seen') or record.get('collected_at'), 'data':raw, 'name':record.get('computer_name'), 'os':record.get('os_caption') or (raw.get('os') or {}).get('caption'), 'cpu':record.get('cpu_name') or (hardware.get('cpu') or {}).get('name'), 'ram_gb':record.get('ram_gb', hardware.get('ram_gb'))}})
@router.get('')
async def search_inventory(q: str = Query('', max_length=160), by: Literal['name', 'serial'] = 'name', page: int = Query(1, ge=1)):
path = '/api/inventory'
params = {'per_page': 20, 'page': page}
if by == 'serial':
if not q.strip():
raise HTTPException(422, 'Indtast et serienummer')
path += '/serial/' + quote(q.strip(), safe='')
params = {}
elif q.strip():
params['computer_name'] = q.strip()
headers = _apigw_headers()
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)) as session:
async with session.get(_apigw_base_url() + path, headers=headers, params=params, allow_redirects=False) as response:
if response.status == 404 and by == 'serial':
record = await find_inventory_in_list(session, headers, serial=q.strip())
return {'items': [inventory_item(record)] if record else [], 'pagination': {}}
if response.status != 200:
raise HTTPException(502, 'API Gateway kunne ikke levere inventory. Kontrollér adgang og tilgængelighed.')
payload = await response.json()
records = [payload] if by == 'serial' else payload['data']
return {'items': [inventory_item(record) for record in records], 'pagination': payload.get('pagination', {})}
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(502, 'Inventory kunne ikke læses fra API Gateway') from None
class InventoryPairInput(BaseModel):
inventory_id: int = Field(gt=0)
async def find_inventory_in_list(session, headers, *, inventory_id=None, serial=None):
"""Some deployed Gateways expose the list but lack working detail routes."""
page = 1
while page <= 1000:
async with session.get(f'{_apigw_base_url()}/api/inventory', headers=headers,
params={'per_page': 100, 'page': page}, allow_redirects=False) as response:
if response.status != 200:
raise HTTPException(502, 'Inventory-listen kunne ikke hentes fra API Gateway')
payload = await response.json()
for record in payload['data']:
if inventory_id is not None and str(record['id']) == str(inventory_id):
return record
if serial is not None and str(record.get('serial_number') or '').strip().casefold() == serial.casefold():
return record
total_pages = int(payload['pagination']['total_pages'])
if page >= total_pages:
return None
page += 1
raise HTTPException(502, 'Inventory-listen er for stor til at gennemføre opslaget')
async def fetch_inventory_record(inventory_id: int):
headers = _apigw_headers()
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)) as session:
async with session.get(f'{_apigw_base_url()}/api/inventory/{inventory_id}', headers=headers, allow_redirects=False) as response:
if response.status == 404:
record = await find_inventory_in_list(session, headers, inventory_id=inventory_id)
if record is None:
raise HTTPException(404, 'Inventory-maskinen findes ikke længere. Søg igen for at opdatere listen.')
return inventory_item(record)
if response.status != 200:
raise HTTPException(502, 'Inventory-maskinen kunne ikke hentes fra API Gateway')
return inventory_item(await response.json())
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(502, 'Inventory kunne ikke læses fra API Gateway') from None
@router.post('/{hardware_id}/pair')
async def pair_inventory(hardware_id: int, payload: InventoryPairInput):
if not execute_query('SELECT id FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,)):
raise HTTPException(404, 'Hardware findes ikke')
item = await fetch_inventory_record(payload.inventory_id)
# Merge in PostgreSQL so concurrent updates and acquisition/ESET/UISP data survive.
rows = execute_query("""UPDATE hardware_assets
SET hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
WHERE id=%s AND deleted_at IS NULL RETURNING id""",
(Json(item['hardware_specs']), hardware_id))
if not rows:
raise HTTPException(404, 'Hardware findes ikke')
return {'hardware_id': hardware_id, 'inventory': item}
@router.delete('/{hardware_id}/pair')
async def unpair_inventory(hardware_id: int):
rows = execute_query("""UPDATE hardware_assets
SET hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) - 'gateway_inventory',
updated_at = NOW()
WHERE id=%s AND deleted_at IS NULL RETURNING id""", (hardware_id,))
if not rows:
raise HTTPException(404, 'Hardware findes ikke')
return {'hardware_id': hardware_id, 'unpaired': True}

View File

@ -401,10 +401,10 @@ async def list_hardware_by_customer(customer_id: int):
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int):
async def list_hardware_by_contact(contact_id: int, direct_only: bool = False):
"""
List hardware assets linked directly to a contact.
Supports both hardware_assets (new) and hardware (legacy) tables.
List contact hardware, including company hardware by default for legacy callers.
Use direct_only=true for the contact's own hardware tab.
"""
# Try new hardware_assets table via hardware_contacts
query_new = """
@ -426,6 +426,8 @@ async def list_hardware_by_contact(contact_id: int):
ORDER BY h.created_at DESC
"""
result_new = execute_query(query_new, (contact_id,))
if direct_only:
return result_new or []
# Also look up hardware_assets by the contact's company (customer link)
query_by_customer = """
@ -518,10 +520,18 @@ async def assign_hardware_to_contact(hardware_id: int, payload: dict):
return {"status": "ok"}
def _validate_bmc_asset_category(category, owner_type):
if category is not None and category not in ('rental', 'internal'):
raise HTTPException(422, 'Vælg Udlejningsvare eller Internt udstyr som asset-type')
if category is not None and owner_type != 'bmc':
raise HTTPException(422, 'Asset-type gælder kun BMC-ejet udstyr')
@router.post("/hardware", response_model=dict)
async def create_hardware(data: dict):
"""Create a new hardware asset."""
try:
_validate_bmc_asset_category(data.get('bmc_asset_category'), data.get('current_owner_type', 'bmc'))
query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
@ -530,9 +540,9 @@ async def create_hardware(data: dict):
anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group,
rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price
rental_default_preparation_price, rental_default_operations_monthly_price, bmc_asset_category
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
@ -564,6 +574,7 @@ async def create_hardware(data: dict):
data.get("rental_default_freight_price"),
data.get("rental_default_preparation_price"),
data.get("rental_default_operations_monthly_price"),
data.get('bmc_asset_category'),
)
result = execute_query(query, params)
if not result:
@ -590,6 +601,8 @@ async def create_hardware(data: dict):
execute_query(ownership_query, ownership_params)
return hardware
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to create hardware: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@ -851,6 +864,11 @@ async def delete_hardware_network_link(hardware_id: int, link_id: int):
async def update_hardware(hardware_id: int, data: dict):
"""Update hardware asset."""
try:
if 'bmc_asset_category' in data:
current = execute_query('SELECT current_owner_type FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,))
if not current:
raise HTTPException(404, 'Hardware not found')
_validate_bmc_asset_category(data['bmc_asset_category'], data.get('current_owner_type', current[0]['current_owner_type']))
# Build dynamic update query
update_fields = []
params = []
@ -863,7 +881,7 @@ async def update_hardware(hardware_id: int, data: dict):
"eset_uuid", "hardware_specs", "eset_group",
"rental_default_start_price", "rental_default_freight_price",
"rental_default_preparation_price", "rental_default_operations_monthly_price",
"location_display_order"
"location_display_order", "bmc_asset_category"
]
for field in allowed_fields:
@ -1524,3 +1542,10 @@ async def list_eset_incidents(
"""
result = execute_query(query, (severity_list, limit))
return result or []
# The intake endpoints use their own prefix and preserve existing hardware CRUD.
from app.modules.hardware.backend.intake import router as hardware_intake_router
router.include_router(hardware_intake_router)
from app.modules.hardware.backend.inventory import router as hardware_inventory_router
router.include_router(hardware_inventory_router)

View File

@ -225,7 +225,8 @@ async def hardware_list(
asset_type: str = Query(None),
rental_scope: str = Query(None),
customer_id: int = Query(None),
q: str = Query(None)
q: str = Query(None),
bmc_asset_category: Optional[str] = Query(None, pattern='^(rental|internal|unassigned)$')
):
"""Display list of BMC-owned assets only."""
query = """
@ -250,6 +251,11 @@ async def hardware_list(
"""
params = []
if bmc_asset_category == 'unassigned':
query += ' AND ha.bmc_asset_category IS NULL'
elif bmc_asset_category:
query += ' AND ha.bmc_asset_category = %s'
params.append(bmc_asset_category)
if status:
query += " AND ha.status = %s"
params.append(status)
@ -296,6 +302,7 @@ async def hardware_list(
"current_status": status,
"current_asset_type": asset_type,
"current_rental_scope": rental_scope,
"current_bmc_asset_category": bmc_asset_category,
"search_query": q
})
@ -469,6 +476,8 @@ async def hardware_detail(request: Request, hardware_id: int):
raise HTTPException(status_code=404, detail="Hardware not found")
hardware = result[0]
specs = hardware.get('hardware_specs') or {}
hardware['acquisition'] = specs.get('acquisition') if isinstance(specs, dict) else None
# Network switches expose their wall-outlet connections as a port map.
switch_ports = []
@ -645,6 +654,11 @@ async def hardware_detail(request: Request, hardware_id: int):
ORDER BY hcr.created_at DESC
"""
cases = execute_query(case_query, (hardware_id,))
if getattr(request.state,'user_id',None) is not None:
from app.core.case_visibility import hidden_case_ids
hidden_cases=hidden_case_ids({'id':request.state.user_id,
'is_superadmin':getattr(request.state,'case_superadmin',False)})
cases=[row for row in (cases or []) if row['case_id'] not in hidden_cases]
# Get tags
tag_query = """

View File

@ -155,6 +155,47 @@ js{% extends "shared/frontend/base.html" %}
<p>Tilføj et nyt hardware asset til systemet</p>
</div>
<div class="form-card mb-3">
<div class="p-3">
<h2 class="h5 mb-3">Hvordan vil du oprette hardware?</h2>
<div class="d-flex flex-wrap gap-2 mb-3">
<button type="button" class="btn btn-outline-primary" id="intake-mode-invoice" onclick="setHardwareIntakeMode('invoice')">Fra leverandørfaktura</button>
<button type="button" class="btn btn-outline-primary" id="intake-mode-barcode" onclick="setHardwareIntakeMode('barcode')">Stregkode / varenummer</button>
<button type="button" class="btn btn-outline-primary" onclick="openHardwareInventory()">Fra PC / Mac inventory</button>
<button type="button" class="btn btn-primary" id="intake-mode-manual" onclick="setHardwareIntakeMode('manual')">100 % manuelt</button>
</div>
<div id="intake-invoice-panel" hidden>
<label for="intake-invoice-search" class="form-label">Find en eksisterende faktura eller upload en ny</label>
<div class="d-flex gap-2 mb-2">
<input id="intake-invoice-search" class="form-control" placeholder="Leverandør, fakturanummer eller filnavn" onkeydown="if(event.key==='Enter'){event.preventDefault();findHardwareInvoiceSources();}">
<button type="button" class="btn btn-outline-primary" onclick="findHardwareInvoiceSources()">Søg</button>
</div>
<label class="form-label mt-2" for="intake-upload">Ny faktura · PDF eller billede</label>
<input id="intake-upload" class="form-control mb-3" type="file" accept=".pdf,.png,.jpg,.jpeg" onchange="uploadHardwareInvoice()">
<div id="intake-invoice-results" class="list-group mb-3"></div>
<div id="intake-invoice-lines"></div>
</div>
<div id="intake-barcode-panel" hidden>
<label for="intake-code" class="form-label">Scan eller indtast EAN/stregkode eller varenummer</label>
<div class="d-flex gap-2 mb-2">
<input id="intake-code" class="form-control" autocomplete="off" placeholder="Scan koden og tryk Enter" oninput="hardwareIntake.code=null;hardwareIntake.productId=null;" onkeydown="if(event.key==='Enter'){event.preventDefault();lookupHardwareCode();}">
<button type="button" class="btn btn-outline-primary" onclick="lookupHardwareCode()">Slå op</button>
</div>
<div id="intake-product-results" class="list-group"></div>
</div>
<div id="intake-inventory-panel" hidden>
<label for="intake-inventory-search">Find PC / Mac i API Gateway</label>
<div class="d-flex gap-2 mb-2">
<select id="intake-inventory-by" aria-label="Søg efter"><option value="name">Maskinnavn</option><option value="serial">Serienummer (præcist)</option></select>
<input id="intake-inventory-search" class="form-control" placeholder="Maskinnavn eller serienummer" onkeydown="if(event.key==='Enter'){event.preventDefault();findHardwareInventory();}">
<button type="button" class="btn btn-outline-primary" onclick="findHardwareInventory()">Søg</button>
</div>
<div id="intake-inventory-results" class="list-group mb-2"></div>
</div>
<div id="intake-message" class="small text-muted" role="status" aria-live="polite">Udfyld formularen manuelt. Intet oprettes, før du gemmer.</div>
</div>
</div>
<div class="form-card">
<form id="hardwareForm" onsubmit="submitForm(event)">
<!-- Basic Information -->
@ -162,7 +203,7 @@ js{% extends "shared/frontend/base.html" %}
<h3 class="form-section-title">📋 Grundlæggende Information</h3>
<div class="form-grid">
<div class="form-group">
<label for="asset_type">Type <span class="required">*</span></label>
<label for="asset_type">Hardwaretype <span class="required">*</span></label>
<select id="asset_type" name="asset_type" required>
<option value="">Vælg type...</option>
<option value="pc">🖥️ PC</option>
@ -176,6 +217,16 @@ js{% extends "shared/frontend/base.html" %}
</select>
</div>
<div class="form-group" id="bmcAssetCategoryGroup" >
<label for="bmc_asset_category">Asset-type</label>
<select id="bmc_asset_category" name="bmc_asset_category" >
<option value="">Ikke valgt</option>
<option value="rental">Udlejningsvare</option>
<option value="internal">Internt udstyr</option>
</select>
<small class="text-muted">Kun BMC-ejet udstyr: udlejning eller eget udstyr til intern brug.</small>
</div>
<div class="form-group">
<label for="brand">Mærke</label>
<input type="text" id="brand" name="brand" placeholder="fx Dell, HP, Lenovo...">
@ -191,6 +242,23 @@ js{% extends "shared/frontend/base.html" %}
<input type="text" id="serial_number" name="serial_number" placeholder="Unik serienummer">
</div>
<div class="form-group">
<label for="intake-count">Antal fysiske enheder</label>
<input id="intake-count" type="number" min="1" max="100" step="1" value="1" required onchange="updateHardwareIntakeQuantity()">
<small>Én hardwarepost pr. enhed. Serienumre må ikke genbruges.</small>
</div>
<div class="form-group full-width" id="intake-serials-group" hidden>
<label for="intake-serials">Serienumre · ét pr. linje, valgfrit</label>
<textarea id="intake-serials" rows="3" placeholder="Ét serienummer pr. fysisk enhed. Lad feltet være tomt, hvis de ikke kendes."></textarea>
</div>
<div class="form-group">
<label for="intake-unit-cost">Indkøbspris pr. enhed ekskl. moms · valgfri</label>
<input id="intake-unit-cost" type="number" min="0" step="0.01" placeholder="Ukendt">
</div>
<div class="form-group">
<label for="intake-currency">Valuta for indkøbspris</label>
<input id="intake-currency" value="DKK" maxlength="3" pattern="[A-Z]{3}" required>
</div>
<div class="form-group">
<label for="internal_asset_id">Internt Asset ID</label>
<input type="text" id="internal_asset_id" name="internal_asset_id" placeholder="BMC-PC-001">
@ -321,7 +389,7 @@ js{% extends "shared/frontend/base.html" %}
<!-- Actions -->
<div class="form-actions">
<a href="/hardware" class="btn btn-secondary">Annuller</a>
<button type="submit" class="btn btn-primary">💾 Gem Hardware</button>
<button type="submit" id="intake-save" class="btn btn-primary">💾 Gem hardware</button>
</div>
</form>
</div>
@ -329,10 +397,13 @@ js{% extends "shared/frontend/base.html" %}
{% endblock %}
{% block extra_js %}
<script src="/static/js/hardware-intake.js"></script>
<script>
function toggleCustomerSelect() {
const ownerType = document.getElementById('current_owner_type').value;
const customerGroup = document.getElementById('customerSelectGroup');
document.getElementById('bmcAssetCategoryGroup').style.display = ownerType === 'bmc' ? '' : 'none';
document.getElementById('bmc_asset_category').disabled = ownerType !== 'bmc';
if (ownerType === 'customer') {
customerGroup.style.display = 'block';
@ -393,19 +464,24 @@ js{% extends "shared/frontend/base.html" %}
}
}
data.bmc_asset_category = data.current_owner_type === 'bmc'
? (document.getElementById('bmc_asset_category').value || null) : null;
try {
const response = await fetch('/api/v1/hardware', {
const response = await fetch('/api/v1/hardware-intake/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
body: JSON.stringify(hardwareIntakePayload(data))
});
if (response.ok) {
const result = await response.json();
alert('Hardware oprettet!');
window.location.href = `/hardware/${result.id}`;
const ids = result.hardware_ids || [];
alert(`${result.already_created ? 'Allerede oprettet' : 'Oprettet'}: ${ids.length} hardwareenhed${ids.length === 1 ? '' : 'er'}`);
window.location.href = ids.length === 1 ? `/hardware/${ids[0]}`
: (data.current_owner_type === 'customer' ? '/hardware/customers' : '/hardware');
} else {
const error = await response.json();
alert('Fejl: ' + (error.detail || 'Kunne ikke oprette hardware'));

View File

@ -173,6 +173,15 @@
#hwTabs .nav-link { border: 1px solid rgba(0,0,0,.1); border-radius: 999px; color: var(--text-secondary); font-weight: 650; padding: .45rem .8rem; }
#hwTabs .nav-link.active { color: var(--accent); background: var(--accent-light); border-color: rgba(15,76,117,.35); }
.hardware-detail-page .card { border: 1px solid rgba(0,0,0,.08) !important; border-radius: .8rem; box-shadow: 0 5px 16px rgba(15,76,117,.06); }
.inventory-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2rem; }
.inventory-specs h6 { font-size: .9rem; margin-bottom: .8rem; }
.inventory-fields > div { display: grid; grid-template-columns: 135px minmax(0, 1fr); gap: .75rem; padding: .4rem 0; border-bottom: 1px solid var(--bs-border-color, #e5e7eb); font-size: .875rem; }
.inventory-fields dt { color: var(--text-secondary); font-weight: 400; }
.inventory-fields dd { margin: 0; overflow-wrap: anywhere; }
.inventory-specs td { font-size: .85rem; overflow-wrap: anywhere; }
.inventory-specs th { font-size: .8rem; color: var(--text-secondary); font-weight: 500; }
.inventory-software summary { font-size: .9rem; font-weight: 500; cursor: pointer; }
@media (max-width: 767px) { .inventory-grid { grid-template-columns: 1fr; gap: 1.5rem; } .inventory-fields > div { grid-template-columns: 110px minmax(0, 1fr); } }
</style>
{% endblock %}
@ -274,6 +283,20 @@
</div>
</div>
{% set paired_inventory = (hardware.hardware_specs or {}).get('gateway_inventory') %}
<details class="card mb-3 p-3" id="inventoryPairPanel">
<summary>PC / Mac inventory · {{ ('Parret med ' ~ paired_inventory.name) if paired_inventory else 'Par maskine' }}</summary>
<p class="small text-muted mt-2">Vælg en maskine for at gemme koblingen og dens specifikationer. Asset-felter, ejer og øvrige integrationer bevares. Ved ny parring erstattes den tidligere inventory-kobling.</p>
<div class="d-flex gap-2 mb-2">
<select id="intake-inventory-by" class="form-select" aria-label="Søg efter"><option value="name">Maskinnavn</option><option value="serial">Serienummer (præcist)</option></select>
<input id="intake-inventory-search" class="form-control" value="{{ hardware.serial_number or '' }}" placeholder="Maskinnavn eller serienummer" onkeydown="if(event.key==='Enter'){event.preventDefault();findHardwareInventory();}">
<button type="button" class="btn btn-outline-primary" onclick="findHardwareInventory()">Søg</button>
{% if paired_inventory %}<button type="button" class="btn btn-outline-secondary" onclick="unpairHardwareInventory()">Fjern parring</button>{% endif %}
</div>
<div id="intake-message" class="small text-muted" role="status" aria-live="polite"></div>
<div id="intake-inventory-results" class="list-group mt-2"></div>
</details>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4 px-2" id="hwTabs" role="tablist">
<li class="nav-item" role="presentation">
@ -352,6 +375,29 @@
{% endif %}
</span>
</div>
{% if hardware.current_owner_type == 'bmc' %}
<div class="info-row">
<span class="info-label">Asset-type</span>
<span class="info-value">{{ {'rental': 'Udlejningsvare', 'internal': 'Internt udstyr'}.get(hardware.bmc_asset_category, 'Ikke valgt') }}</span>
</div>
{% endif %}
{% if hardware.acquisition %}
<div class="info-row">
<span class="info-label">Oprettelsesgrundlag</span>
<span class="info-value">
{% if hardware.acquisition.mode == 'invoice' %}
Leverandørfaktura {{ hardware.acquisition.header.invoice_number or hardware.acquisition.header.filename or '' }}
<div class="small text-muted">{{ hardware.acquisition.header.vendor_name or '' }} · {{ hardware.acquisition.line.description }}</div>
{% if hardware.acquisition.header.document_available %}<a href="/api/v1/hardware-intake/{{ hardware.id }}/document" target="_blank" rel="noopener">Åbn fakturabilag</a>{% endif %}
{% elif hardware.acquisition.mode == 'barcode' %}
Stregkode / varenummer: {{ hardware.acquisition.code }}
{% else %}Manuelt oprettet{% endif %}
{% if hardware.acquisition.approved_unit_cost is not none %}
<div class="small text-muted">Indkøb ekskl. moms: {{ hardware.acquisition.approved_unit_cost }} {{ hardware.acquisition.approved_currency }}</div>
{% endif %}
</span>
</div>
{% endif %}
<div class="info-row">
<span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
@ -609,6 +655,12 @@
<!-- Tab: Specs -->
<div class="tab-pane fade" id="eset-specs" role="tabpanel">
{% set inventory = (hardware.hardware_specs or {}).get('gateway_inventory') %}
{% if inventory %}
{% include "modules/hardware/templates/inventory_specs.html" %}
{% endif %}
{% if not inventory or (eset_specs and (eset_specs.os_name or eset_specs.primary_local_ip or eset_specs.cpu_models or eset_specs.deployed_components)) %}
<div class="card shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6>
@ -709,11 +761,12 @@
</table>
</div>
{% else %}
<div class="text-muted">Ingen ESET specifikationer fundet endnu.</div>
{% if not inventory %}<div class="text-muted">Ingen specifikationer fundet endnu.</div>{% endif %}
{% endif %}
</div>
</div>
{% endif %}
{% if switch_ports %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
@ -773,18 +826,16 @@
</div>
{% endif %}
{% if hardware.hardware_specs %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-shield-check me-2"></i>ESET Data</h6>
</div>
{% if hardware.hardware_specs and not inventory %}
<details class="card mt-4 shadow-sm border-0">
<summary class="p-3 text-muted small">Tekniske diagnosedata</summary>
<div class="card-body">
<div class="mb-2 text-muted" style="font-size: 0.85rem;">
Rå data fra ESET (til match/diagnose)
Specifikationsdata til diagnose
</div>
<pre class="p-3 rounded" style="background: var(--bg-body); border: 1px solid rgba(0,0,0,0.1); max-height: 420px; overflow: auto; font-size: 0.85rem;">{{ hardware.hardware_specs | tojson(indent=2) }}</pre>
</div>
</div>
</details>
{% endif %}
</div>
@ -871,13 +922,13 @@
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Klargoring</div>
<div class="price-label">Klargøring</div>
<div class="price-value">{{ "{:,.2f}".format(preparation_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
<div class="col-md-6 col-lg-3">
<div class="price-tile">
<div class="price-label">Drift pr. maned</div>
<div class="price-label">Drift pr. måned</div>
<div class="price-value">{{ "{:,.2f}".format(operations_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div>
</div>
@ -1087,11 +1138,22 @@
</select>
</div>
<div class="col-md-6">
<label class="form-label">Sag ID</label>
<input type="number" class="form-control" id="quickRentSagId" placeholder="fx 123" min="1" required>
<label class="form-label" for="quickRentCaseSearch">Kundens åbne sager</label>
<input type="search" class="form-control mb-2" id="quickRentCaseSearch" placeholder="Søg på titel eller sagsnummer" disabled>
<select class="form-select" id="quickRentSagId" required disabled><option value="">Vælg kunde først</option></select>
<div class="small text-muted mt-1" id="quickRentCaseHelp" role="status"></div>
<button type="button" class="btn btn-sm btn-link px-0" id="quickRentNewCaseToggle" disabled onclick="document.getElementById('quickRentNewCasePanel').hidden=!document.getElementById('quickRentNewCasePanel').hidden">+ Opret ny sag til kunden</button>
</div>
<div class="col-12" id="quickRentNewCasePanel" hidden>
<div class="border rounded p-3">
<label class="form-label" for="quickRentNewCaseTitle">Titel på ny sag</label>
<input class="form-control mb-2" id="quickRentNewCaseTitle" placeholder="Fx Udlejning af arbejdscomputer">
<div class="small text-muted mb-2">Sagen knyttes til den valgte kunde og dette asset. Den gemmes med det samme og vælges til udlejningen.</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="quickRentCreateCaseBtn" onclick="createQuickRentCase()">Opret og vælg sag</button>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Start dato</label>
<label class="form-label">Startdato</label>
<input type="date" class="form-control" id="quickRentStartDate" required>
</div>
<div class="col-md-4">
@ -1117,7 +1179,7 @@
>
</div>
<div class="col-md-4">
<label class="form-label">Klargoring</label>
<label class="form-label">Klargøring</label>
<input
type="number"
class="form-control"
@ -1128,7 +1190,7 @@
>
</div>
<div class="col-md-4">
<label class="form-label">Drift pr. maned</label>
<label class="form-label">Drift pr. måned</label>
<input
type="number"
class="form-control"
@ -1139,12 +1201,9 @@
required
>
</div>
<div class="col-md-4">
<label class="form-label">Drift i ordre (maneder)</label>
<input type="number" class="form-control" id="quickRentInitialMonths" min="1" max="12" value="2">
</div>
</div>
<div class="form-text mt-2">Standarden er 2 mdr. drift i opstartsordren (1+1).</div>
<div class="form-text mt-2">Drift oprettes som et løbende månedsabonnement. Opstartsordren indeholder kun startpris, fragt og klargøring.</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
@ -1350,6 +1409,14 @@
{% endblock %}
{% block extra_js %}
<script src="/static/js/hardware-intake.js?v=inventory-pair-1"></script>
<script>
hardwareIntake.pairHardwareId = {{ hardware.id | tojson }};
document.getElementById('intake-inventory-by').value = {{ ('serial' if hardware.serial_number else 'name') | tojson }};
document.getElementById('inventoryPairPanel').addEventListener('toggle', function () {
if (this.open && !hardwareIntake.inventoryItems) findHardwareInventory();
});
</script>
<script>
async function syncEsetData() {
try {
@ -1489,6 +1556,53 @@
}));
});
let quickRentCaseGeneration = 0;
async function loadQuickRentCases() {
const version = ++quickRentCaseGeneration;
const customer = document.getElementById('quickRentCustomerId').value;
const select = document.getElementById('quickRentSagId');
const help = document.getElementById('quickRentCaseHelp');
select.innerHTML = '<option value="">Vælg sag</option>';
select.disabled = true;
document.getElementById('quickRentCaseSearch').disabled = !customer;
document.getElementById('quickRentNewCaseToggle').disabled = !customer;
refreshQuickRentPlan();
help.textContent = customer ? 'Henter kundens åbne sager…' : '';
if (!customer) return;
try {
const query = document.getElementById('quickRentCaseSearch').value;
const data = await intakeFetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/cases?customer_id=${encodeURIComponent(customer)}&q=${encodeURIComponent(query)}`);
if (version !== quickRentCaseGeneration) return;
for (const item of data.cases) select.add(new Option(`#${item.id} · ${item.titel} · ${item.status}`, item.id));
select.disabled = !data.cases.length;
help.textContent = data.has_more ? 'Viser de første 100 sager. Søg for at indsnævre listen.' : (data.cases.length ? 'Kun kundens åbne sager vises.' : 'Ingen åbne sager fundet. Du kan oprette en ny sag nedenfor.');
} catch (error) { if (version === quickRentCaseGeneration) help.textContent = error.message; }
}
async function createQuickRentCase() {
const customer = document.getElementById('quickRentCustomerId').value;
const title = document.getElementById('quickRentNewCaseTitle').value.trim();
if (!customer || !title) { document.getElementById('quickRentCaseHelp').textContent = 'Vælg kunde og angiv en titel.'; return; }
const button = document.getElementById('quickRentCreateCaseBtn');
button.disabled = true;
try {
const item = await intakeFetch('/api/v1/sag', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({titel:title,customer_id:Number(customer),type:'ticket',status:'Ny',hardware_ids:[{{ hardware.id }}]})});
if (customer !== document.getElementById('quickRentCustomerId').value) {
document.getElementById('quickRentCaseHelp').textContent = `Sag #${item.id} blev oprettet til den tidligere valgte kunde.`;
return;
}
++quickRentCaseGeneration;
const select = document.getElementById('quickRentSagId');
select.add(new Option(`#${item.id} · ${item.titel} · Ny`,item.id));
select.disabled = false;
select.value = String(item.id);
document.getElementById('quickRentNewCasePanel').hidden = true;
document.getElementById('quickRentNewCaseTitle').value = '';
document.getElementById('quickRentCaseHelp').textContent = `Sag #${item.id} er oprettet og knyttet til dette asset.`;
refreshQuickRentPlan();
} catch (error) { document.getElementById('quickRentCaseHelp').textContent = error.message; }
finally { button.disabled = false; }
}
async function submitQuickRent() {
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
@ -1497,7 +1611,6 @@
const freightPrice = Number(document.getElementById('quickRentFreightPrice').value || 0);
const preparationPrice = Number(document.getElementById('quickRentPreparationPrice').value || 0);
const operationsMonthlyPrice = Number(document.getElementById('quickRentOperationsMonthlyPrice').value || 0);
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er påkrævet.');
@ -1524,7 +1637,7 @@
freight_price: freightPrice,
preparation_price: preparationPrice,
operations_monthly_price: operationsMonthlyPrice,
initial_operations_months: initialOperationsMonths,
initial_operations_months: 0,
notice_period_days: 30
})
});
@ -1569,13 +1682,14 @@
if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Vælg kunde og sag for at se, hvad der bliver oprettet.';
submitBtn.disabled = false;
submitBtn.disabled = true;
return;
}
try {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Tjekker abonnement på sagen...';
submitBtn.disabled = true;
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json();
@ -1584,6 +1698,7 @@
throw new Error((data && data.detail) || 'Preview fejlede');
}
if (customerId !== Number(document.getElementById('quickRentCustomerId').value) || sagId !== Number(document.getElementById('quickRentSagId').value)) return;
submitBtn.disabled = !data.can_submit;
if (data.action === 'reuse') {
@ -1596,6 +1711,7 @@
infoEl.textContent = data.message || 'Klar.';
} catch (error) {
if (customerId !== Number(document.getElementById('quickRentCustomerId').value) || sagId !== Number(document.getElementById('quickRentSagId').value)) return;
submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3';
infoEl.textContent = `Fejl i forhåndsvisning: ${error.message}. Du kan stadig prøve at oprette.`;
@ -1727,10 +1843,12 @@
quickRentStartDate.value = new Date().toISOString().slice(0, 10);
}
if (quickRentCustomerId) {
quickRentCustomerId.addEventListener('change', refreshQuickRentPlan);
quickRentCustomerId.addEventListener('change', () => { document.getElementById('quickRentCaseSearch').value=''; document.getElementById('quickRentNewCasePanel').hidden=true; document.getElementById('quickRentNewCaseTitle').value=''; loadQuickRentCases(); });
let caseSearchTimer;
document.getElementById('quickRentCaseSearch').addEventListener('input', () => { clearTimeout(caseSearchTimer); caseSearchTimer=setTimeout(loadQuickRentCases,200); });
}
if (quickRentSagId) {
quickRentSagId.addEventListener('input', refreshQuickRentPlan);
quickRentSagId.addEventListener('change', refreshQuickRentPlan);
}
if (quickRentModal) {
quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan);

View File

@ -162,7 +162,7 @@
<h3 class="form-section-title">📋 Grundlæggende Information</h3>
<div class="form-grid">
<div class="form-group">
<label for="asset_type">Type <span class="required">*</span></label>
<label for="asset_type">Hardwaretype <span class="required">*</span></label>
<select id="asset_type" name="asset_type" required>
<option value="">Vælg type...</option>
<option value="pc" {% if hardware.asset_type == 'pc' %}selected{% endif %}>🖥️ PC</option>
@ -176,6 +176,16 @@
</select>
</div>
<div class="form-group" id="bmcAssetCategoryGroup" {% if hardware.current_owner_type != 'bmc' %}style="display:none"{% endif %}>
<label for="bmc_asset_category">Asset-type</label>
<select id="bmc_asset_category" name="bmc_asset_category" {% if hardware.current_owner_type != 'bmc' %}disabled{% endif %}>
<option value="">Ikke valgt</option>
<option value="rental" {% if hardware.bmc_asset_category == 'rental' %}selected{% endif %}>Udlejningsvare</option>
<option value="internal" {% if hardware.bmc_asset_category == 'internal' %}selected{% endif %}>Internt udstyr</option>
</select>
<small class="text-muted">Kun BMC-ejet udstyr: udlejning eller eget udstyr til intern brug.</small>
</div>
<div class="form-group">
<label for="brand">Mærke</label>
<input type="text" id="brand" name="brand" value="{{ hardware.brand or '' }}" placeholder="fx Dell, HP, Lenovo...">
@ -361,6 +371,8 @@
function toggleCustomerSelect() {
const ownerType = document.getElementById('current_owner_type').value;
const customerGroup = document.getElementById('customerSelectGroup');
document.getElementById('bmcAssetCategoryGroup').style.display = ownerType === 'bmc' ? '' : 'none';
document.getElementById('bmc_asset_category').disabled = ownerType !== 'bmc';
if (ownerType === 'customer') {
customerGroup.style.display = 'block';
@ -394,6 +406,9 @@
}
}
data.bmc_asset_category = data.current_owner_type === 'bmc'
? (document.getElementById('bmc_asset_category').value || null) : null;
try {
const response = await fetch('/api/v1/hardware/{{ hardware.id }}', {
method: 'PATCH',

View File

@ -289,6 +289,16 @@
</select>
</div>
<div class="filter-group">
<label for="bmc_asset_category">Asset-type</label>
<select name="bmc_asset_category" id="bmc_asset_category">
<option value="">Alle asset-typer</option>
<option value="rental" {% if current_bmc_asset_category == 'rental' %}selected{% endif %}>Udlejningsvare</option>
<option value="internal" {% if current_bmc_asset_category == 'internal' %}selected{% endif %}>Internt udstyr</option>
<option value="unassigned" {% if current_bmc_asset_category == 'unassigned' %}selected{% endif %}>Ikke valgt</option>
</select>
</div>
<div class="filter-group">
<label for="rental_scope">Udlejning</label>
<select name="rental_scope" id="rental_scope">
@ -346,6 +356,10 @@
<span class="hardware-detail-label">Type:</span>
<span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div>
<div class="hardware-detail-row">
<span class="hardware-detail-label">Asset-type:</span>
<span class="hardware-detail-value">{{ {'rental': 'Udlejningsvare', 'internal': 'Internt udstyr'}.get(item.bmc_asset_category, 'Ikke valgt') }}</span>
</div>
{% if item.anydesk_id or item.anydesk_link %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">AnyDesk:</span>
@ -381,7 +395,7 @@
{% if item.is_currently_rented %}
Udlejet
{% else %}
Ledig/Intern
Ikke udlejet
{% endif %}
</span>
</div>
@ -408,7 +422,8 @@
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Hardwaretype</th>
<th>Asset-type</th>
<th>Serienr.</th>
<th>Ejer</th>
<th>Udlejning</th>
@ -422,13 +437,14 @@
<tr>
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
<td>{{ item.asset_type|title }}</td>
<td>{{ {'rental': 'Udlejningsvare', 'internal': 'Internt udstyr'}.get(item.bmc_asset_category, 'Ikke valgt') }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td>
<td>
{% if item.is_currently_rented %}
<span class="status-badge" style="background-color:#0f4c75;color:#fff;">Udlejet</span>
{% else %}
<span class="status-badge" style="background-color:#6c757d;color:#fff;">Ledig/Intern</span>
<span class="status-badge" style="background-color:#6c757d;color:#fff;">Ikke udlejet</span>
{% endif %}
</td>
<td>
@ -470,7 +486,7 @@
{% block extra_js %}
<script>
// Auto-submit filter form on change
document.querySelectorAll('#asset_type, #status, #rental_scope').forEach(select => {
document.querySelectorAll('#asset_type, #status, #rental_scope, #bmc_asset_category').forEach(select => {
select.addEventListener('change', () => {
select.form.submit();
});

View File

@ -0,0 +1,53 @@
{% set data = inventory.get('data') or {} %}
{% set machine = data.get('hardware') or {} %}
{% set identity = data.get('identity') or {} %}
{% set os = data.get('os') or {} %}
{% set network = data.get('network') or {} %}
{% set cpu = machine.get('cpu') or {} %}
{% macro fields(items) %}
<dl class="inventory-fields mb-0">
{% for label, value in items %}{% if value is not none and value != '' %}
<div><dt>{{ label }}</dt><dd>{{ value }}</dd></div>
{% endif %}{% endfor %}
</dl>
{% endmacro %}
<div class="card border-0 shadow-sm mb-3 inventory-specs">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3">
<div><div class="text-muted small">PC / Mac inventory</div><h5 class="mb-1">{{ inventory.get('name') or identity.get('computer_name') or 'Parret maskine' }}</h5><span class="text-muted">{{ machine.get('manufacturer') or '' }} {{ machine.get('model') or '' }}</span></div>
<div class="small text-muted">Indsamlet<br><strong>{{ (inventory.get('collected_at') or data.get('collected_at') or 'Ukendt') | replace('T', ' ') }}</strong></div>
</div>
<div class="inventory-grid">
<section><h6><i class="bi bi-cpu me-2"></i>Hardware</h6>
{{ fields([('Serienummer', machine.get('serial') or machine.get('serial_number')), ('Processor', inventory.get('cpu') or cpu.get('name')), ('Kerner / tråde', (cpu.get('cores', '–') ~ ' / ' ~ cpu.get('threads', '–')) if cpu else none), ('Hukommelse', ((inventory.get('ram_gb') or machine.get('ram_gb')) ~ ' GB') if (inventory.get('ram_gb') or machine.get('ram_gb')) else none), ('Grafikkort', machine.get('gpu')), ('BIOS-version', machine.get('bios_version')), ('BIOS-dato', machine.get('bios_date'))]) }}
</section>
<section><h6><i class="bi bi-window me-2"></i>System</h6>
{{ fields([('Operativsystem', inventory.get('os') or os.get('caption')), ('Version', os.get('version')), ('Build', os.get('build')), ('Installeret', os.get('install_date')), ('Seneste opstart', os.get('last_boot')), ('Oppetid', (os.get('uptime_hours') ~ ' timer') if os.get('uptime_hours') is not none else none), ('Domæne', identity.get('domain')), ('Bruger', identity.get('user')), ('Offentlig IP', network.get('wan_ip'))]) }}
</section>
</div>
{% if machine.get('disks') %}
<section class="mt-4"><h6><i class="bi bi-device-hdd me-2"></i>Diske <span class="text-muted">({{ machine.disks | length }})</span></h6>
<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Model</th><th>Forbindelse</th><th class="text-end">Kapacitet</th></tr></thead><tbody>
{% for disk in machine.disks %}<tr><td>{{ disk.get('model') or '–' }}</td><td>{{ disk.get('interface') or '–' }}</td><td class="text-end text-nowrap">{{ (disk.size_gb ~ ' GB') if disk.get('size_gb') is not none else '–' }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endif %}
{% if network.get('adapters') %}
<section class="mt-4"><h6><i class="bi bi-ethernet me-2"></i>Netværk</h6>
<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Netværkskort</th><th>IP-adresser</th><th>MAC-adresse</th><th>DHCP</th></tr></thead><tbody>
{% for adapter in network.adapters %}<tr><td>{{ adapter.get('description') or '–' }}</td><td>{% set ips = adapter.get('ip') or [] %}{% if ips is string %}{{ ips }}{% else %}{% for ip in ips %}<div>{{ ip }}</div>{% else %}–{% endfor %}{% endif %}</td><td class="text-nowrap">{{ adapter.get('mac') or '–' }}</td><td>{{ 'Ja' if adapter.get('dhcp_enabled') == true else ('Nej' if adapter.get('dhcp_enabled') == false else '–') }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endif %}
{% set defender = (data.get('security') or {}).get('windows_defender') %}
{% if defender %}<section class="mt-4"><h6><i class="bi bi-shield-check me-2"></i>Windows Defender</h6>
{{ fields([('Aktiveret', 'Ja' if defender.get('enabled') == true else ('Nej' if defender.get('enabled') == false else 'Ukendt')), ('Realtidsbeskyttelse', 'Ja' if defender.get('real_time_protection') == true else ('Nej' if defender.get('real_time_protection') == false else 'Ukendt')), ('Opdateret', defender.get('updated'))]) }}</section>{% endif %}
{% if data.get('software') %}
<details class="inventory-software mt-4"><summary>Installerede programmer <span class="text-muted">({{ data.software | length }})</span></summary>
<div class="table-responsive mt-2" style="max-height: 420px; overflow: auto;"><table class="table table-sm mb-0"><thead><tr><th>Program</th><th>Version</th><th>Udgiver</th></tr></thead><tbody>
{% for program in data.software %}<tr><td>{{ program.get('name') or '–' }}</td><td>{{ program.get('version') or '–' }}</td><td>{{ program.get('publisher') or '–' }}</td></tr>{% endfor %}
</tbody></table></div>
</details>
{% endif %}
</div>
</div>

View File

@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from app.modules.orders.backend.economic_export import ordre_economic_export_service
from app.modules.orders.backend.service import aggregate_order_lines
from app.modules.also.backend.billing import period_amount_line, check_period_amounts
logger = logging.getLogger(__name__)
router = APIRouter()
@ -37,6 +38,24 @@ def _snapshot_lines(lines):
return snapshot_draft_lines(lines) if active_connection() else lines
def _also_period_sources(lines):
"""Restore missing period metadata in old drafts from their linked import rows."""
from app.core.database import execute_query
ids = [line['source_id'] for line in lines if line.get('source_type') == 'also_cloud'
and line.get('source_id') and not (line.get('meta') or {}).get('billing_period')]
if not ids:
return lines
rows = execute_query('SELECT id,charge_interval FROM also_import_lines WHERE id = ANY(%s)', (ids,)) or []
periods = {row['id']: row.get('charge_interval') for row in rows}
result = []
for line in lines:
if line.get('source_type') == 'also_cloud' and periods.get(line.get('source_id')):
line = dict(line, meta=dict(line.get('meta') or {}))
line['meta'].setdefault('billing_period', periods[line['source_id']])
result.append(line)
return result
async def _check_draft_prices(http_request, customer_id, lines, draft_id=None):
from app.products.backend.economic_documents import active_connection
if not active_connection():
@ -48,6 +67,7 @@ async def _check_draft_prices(http_request, customer_id, lines, draft_id=None):
actor = await get_current_user(http_request, await security(http_request))
saved = execute_query_single('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (draft_id,)) if draft_id else None
saved_lines = _safe_json_field(saved['lines_json']) if saved and saved['customer_id'] == customer_id else []
saved_lines = [period_amount_line(line) for line in _also_period_sources(saved_lines or [])]
validate_manual_prices(lines, customer_id, actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], 'economic.pricing.override'), saved_lines)
@ -207,7 +227,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
draft_row = execute_query_single(
"""
SELECT id, sync_status, export_idempotency_key, export_status_json
SELECT id, customer_id, lines_json, sync_status, export_idempotency_key, export_status_json
FROM ordre_drafts
WHERE id = %s
""",
@ -234,6 +254,11 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
)
line_payload = [line.model_dump() for line in request.lines]
saved_lines = (_safe_json_field(draft_row.get('lines_json')) or []) if request.draft_id else []
if any(line.get('source_type') == 'also_cloud' for line in line_payload + saved_lines):
if not request.draft_id or draft_row.get('customer_id') != request.customer_id:
raise HTTPException(409, 'ALSO-eksport kræver den godkendte kladdes kunde')
check_period_amounts(line_payload, _also_period_sources(saved_lines))
recovered_export = None
if workflow_guarded:
from app.workflows.export_guard import recovered_result
@ -439,6 +464,9 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
raise HTTPException(status_code=404, detail="Draft not found")
draft["lines_json"] = _safe_json_field(draft.get("lines_json")) or []
if (draft.get('sync_status') == 'pending' and not draft.get('economic_order_number')
and not draft.get('economic_invoice_number') and not draft.get('export_idempotency_key')):
draft['lines_json'] = [period_amount_line(line) for line in _also_period_sources(draft['lines_json'])]
draft["export_status_json"] = _safe_json_field(draft.get("export_status_json")) or {}
return draft
except HTTPException:

View File

@ -473,8 +473,10 @@
tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported';
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
const lockLine = isExportedLine && !mayEditExportedDraft;
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
const lockLine = (isExportedLine && !mayEditExportedDraft) || line.source_type === 'also_cloud';
const descriptionField = line.source_type === 'also_cloud'
? `<div class="small" style="min-width:260px; white-space:pre-wrap; overflow-wrap:anywhere;">${escapeHtml(line.description || '')}</div>`
: `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;

View File

@ -0,0 +1,583 @@
"""Shared bookings and absence on the existing planner/time models."""
from datetime import date, datetime, time, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from app.core.auth_dependencies import get_current_user
from app.core.database import atomic_transaction, execute_query, execute_query_single
from app.core.case_visibility import require_case,hidden_case_ids,filter_data
from .router import TZ, _aware,_merge_minutes
from .work import audit, require_target, require_schema,notify_case,db_utc,FINISHED
router = APIRouter(dependencies=[Depends(require_schema)])
class EventInput(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10000)
sag_id: int | None = None
user_ids: list[int] = Field(min_length=1, max_length=100)
starts_at: datetime
ends_at: datetime
kind: Literal['work', 'meeting', 'internal'] = 'work'
confirm_conflicts: bool = False
version: int | None = Field(default=None, ge=1)
@model_validator(mode='after')
def validate_period(self):
if self.starts_at.tzinfo is None or self.ends_at.tzinfo is None:
raise ValueError('Tidspunkter skal indeholde tidszone')
if self.ends_at <= self.starts_at or self.ends_at-self.starts_at > timedelta(days=370):
raise ValueError('Ugyldig periode')
if not self.title.strip():
raise ValueError('Titel skal udfyldes')
return self
def employee_locks(ids):
for uid in sorted(set(ids)):
execute_query('SELECT pg_advisory_xact_lock(%s,%s)', (2521, uid))
def check_event_conflicts(ids, start, end, event_id=None):
rows=execute_query('''SELECT user_id FROM planner_allocations WHERE user_id=ANY(%s)
AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s AND (%s IS NULL OR event_id IS DISTINCT FROM %s)
UNION SELECT user_id FROM planner_absences WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
UNION SELECT user_id FROM quick_time_items WHERE user_id=ANY(%s) AND state='planned' AND starts_at<%s AND ends_at>%s
UNION SELECT user_id FROM planner_external_busy WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s''',
(ids,end,start,event_id,event_id,ids,end,start,ids,end,start,ids,end,start)) or []
result=[{'user_id':r['user_id'],'type':'time_conflict'} for r in rows]
schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
for uid in ids:
if outside_work_hours(uid,start,end,schedules):
result.append({'user_id':uid,'type':'outside_work_hours'})
return result
def outside_work_hours(uid,start,end,schedules):
from datetime import timezone
rows=[r for r in schedules if r['user_id']==uid]
if not rows:
return False # Unknown norm is shown in capacity, rather than inferred.
a,b=_aware(start),_aware(end)
day=a.astimezone(TZ).date()
windows=[]
while day<=b.astimezone(TZ).date():
for row in rows:
if row['weekday']!=day.weekday() or (row.get('valid_from') and day<row['valid_from']) or (row.get('valid_to') and day>row['valid_to']):
continue
windows.append((datetime.combine(day,row['start_time'],TZ).astimezone(timezone.utc),
datetime.combine(day,row['end_time'],TZ).astimezone(timezone.utc)))
day+=timedelta(days=1)
start_utc,end_utc=a.astimezone(timezone.utc),b.astimezone(timezone.utc)
return _merge_minutes(windows,start_utc,end_utc)<round((end_utc-start_utc).total_seconds()/60)
def mark_conflicts(bookings,others):
"""Half-open intervals: adjacent appointments are never overlaps."""
active={}
entries=[dict(r,_booking=True) for r in bookings]+[dict(r,_booking=False) for r in others]
flags=set()
for row in sorted(entries,key=lambda r:(r['user_id'],_aware(r['starts_at']))):
uid=row['user_id']
current=[r for r in active.get(uid,[]) if _aware(r['ends_at'])>_aware(row['starts_at'])]
for old in current:
if row['_booking']:
flags.add(row['id'])
if old['_booking']:
flags.add(old['id'])
current.append(row)
active[uid]=current
for row in bookings:
row['has_conflict']=row['id'] in flags
def save_event(payload, user, event_id=None):
if payload.sag_id:
require_case(user,payload.sag_id)
ids = sorted(set(payload.user_ids))
for uid in ids:
require_target(user, uid, True)
with atomic_transaction():
old = None
old_ids = []
if event_id:
old = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (event_id,))
if not old:
raise HTTPException(404, 'Aftalen findes ikke')
if old['sag_id']:
require_case(user,old['sag_id'])
if old['sag_id']!=payload.sag_id:
private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(old['sag_id'],))
if private and private['restricted'] and not (user.get('is_superadmin') or 'cases.manage_confidential' in set(user.get('permissions') or [])):
raise HTTPException(403,'Fortrolige aftalers sagstilknytning kræver rettighed til fortrolighed')
if payload.version != old['version']:
raise HTTPException(409, 'Aftalen er ændret. Genindlæs.')
old_rows = execute_query('SELECT * FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL', (event_id,)) or []
old_ids = [r['user_id'] for r in old_rows]
for uid in old_ids:
require_target(user, uid, True)
if any(r['locked'] for r in old_rows):
raise HTTPException(409, 'Fjern tidslås før aftalen ændres')
employee_locks(ids + old_ids)
active = execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) AND is_active', (ids,)) or []
if len(active) != len(ids):
raise HTTPException(400, 'Vælg aktive medarbejdere')
if payload.sag_id:
case=execute_query_single('SELECT id,status FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(payload.sag_id,))
if not case:
raise HTTPException(404,'Sagen findes ikke')
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
if str(case['status']).lower() in FINISHED|set(_fetch_closed_case_statuses()):
raise HTTPException(409,'Genåbn sagen før den planlægges')
conflicts = check_event_conflicts(ids,payload.starts_at,payload.ends_at,event_id)
if conflicts and not payload.confirm_conflicts:
message='Aftalen ligger uden for arbejdstiden' if all(r['type']=='outside_work_hours' for r in conflicts) else 'Aftalen overlapper med en anden reservation'
raise HTTPException(409, {'message': message, 'conflicts': conflicts, 'confirm_conflicts': True})
values = (payload.sag_id,payload.title.strip(),payload.description,payload.kind,payload.starts_at,payload.ends_at,user['id'])
if event_id:
event = execute_query_single('''UPDATE planner_events SET sag_id=%s,title=%s,description=%s,kind=%s,
starts_at=%s,ends_at=%s,updated_by=%s,updated_at=NOW(),version=version+1 WHERE id=%s RETURNING *''', (*values,event_id))
else:
event = execute_query_single('''INSERT INTO planner_events(sag_id,title,description,kind,starts_at,ends_at,updated_by,created_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''', (*values,user['id']))
event_id = event['id']
execute_query('UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE event_id=%s AND deleted_at IS NULL AND NOT(user_id=ANY(%s))', (user['id'],event_id,ids),fetch=False)
for uid in ids:
existing = execute_query_single('SELECT id FROM planner_allocations WHERE event_id=%s AND user_id=%s AND deleted_at IS NULL', (event_id,uid))
if existing:
execute_query('''UPDATE planner_allocations SET sag_id=%s,starts_at=%s,ends_at=%s,
note=%s,updated_by=%s,updated_at=NOW(),version=version+1 WHERE id=%s''',
(payload.sag_id,payload.starts_at,payload.ends_at,payload.description,user['id'],existing['id']),fetch=False)
else:
execute_query('''INSERT INTO planner_allocations(event_id,sag_id,user_id,starts_at,ends_at,note,created_by,updated_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s)''', (event_id,payload.sag_id,uid,payload.starts_at,payload.ends_at,payload.description,user['id'],user['id']),fetch=False)
audit(user,'event_update' if old else 'event_create','event',event_id,old,event,payload.sag_id)
notify_case(user,payload.sag_id,f'event:{event_id}:{event["version"]}',
'En fast aftale er oprettet eller ændret.',ids+old_ids)
return {'event': event, 'conflicts': conflicts}
@router.post('/events', status_code=201)
def create_event(payload: EventInput, user: dict = Depends(get_current_user)):
return save_event(payload,user)
@router.patch('/events/{event_id}')
def update_event(event_id: int, payload: EventInput, user: dict = Depends(get_current_user)):
return save_event(payload,user,event_id)
@router.delete('/events/{event_id}')
def cancel_event(event_id: int, version: int, user: dict = Depends(get_current_user)):
with atomic_transaction():
old = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (event_id,))
if not old:
raise HTTPException(404, 'Aftalen findes ikke')
if old['sag_id']:
require_case(user,old['sag_id'])
if old['version'] != version:
raise HTTPException(409, 'Aftalen er ændret')
participants = execute_query('SELECT user_id,locked FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL', (event_id,)) or []
for row in participants:
require_target(user,row['user_id'],True)
if row['locked']:
raise HTTPException(409,'Aftalen er låst')
employee_locks([r['user_id'] for r in participants])
execute_query('UPDATE planner_events SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s', (user['id'],event_id),fetch=False)
execute_query('UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE event_id=%s AND deleted_at IS NULL', (user['id'],event_id),fetch=False)
audit(user,'event_cancel','event',event_id,old,{'cancelled':True},old['sag_id'])
notify_case(user,old['sag_id'],f'event:{event_id}:cancel:{version}',
'En fast aftale er annulleret.',[r['user_id'] for r in participants])
return {'cancelled': True}
class AbsenceInput(BaseModel):
user_id: int = Field(gt=0)
starts_at: datetime
ends_at: datetime
kind: str | None = Field(default=None,max_length=40)
note: str | None = Field(default=None,max_length=2000)
version: int | None = Field(default=None,ge=1)
whole_day_start: date | None = None
whole_day_end: date | None = None
@model_validator(mode='after')
def validate_period(self):
if self.whole_day_start is not None or self.whole_day_end is not None:
if self.whole_day_start is None or self.whole_day_end is None or self.whole_day_end<self.whole_day_start:
raise ValueError('Angiv første og sidste fraværsdag')
self.starts_at=datetime.combine(self.whole_day_start,time.min,TZ)
self.ends_at=datetime.combine(self.whole_day_end+timedelta(days=1),time.min,TZ)
if self.starts_at.tzinfo is None or self.ends_at.tzinfo is None or self.ends_at <= self.starts_at:
raise ValueError('Angiv gyldige tidspunkter med tidszone')
if self.ends_at-self.starts_at > timedelta(days=370):
raise ValueError('Fravær må højst dække 370 dage')
return self
def require_absence(user, target):
require_target(user,target,True)
permission = 'planner.absence_own' if target == int(user['id']) else 'planner.absence_team'
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {permission,'planner.admin'}):
raise HTTPException(403,'Du må ikke registrere dette fravær')
def can_see_absence_reason(user,target):
return bool(user.get('is_superadmin') or target==int(user['id']) or set(user.get('permissions') or []) & {'planner.absence_reason','planner.admin'})
def visible_absence(row,user):
result=dict(row)
if not can_see_absence_reason(user,row['user_id']):
result['kind']='unavailable'
result['note']=None
start,end=(_aware(row[k]).astimezone(TZ) for k in ('starts_at','ends_at'))
result['whole_days']=start.time()==time.min and end.time()==time.min
result['all_day']=start.date()!=end.date() or result['whole_days']
result['display_start']=start.date().isoformat()
result['display_end']=(end.date()+timedelta(days=bool(end.time()!=time.min))).isoformat()
return result
def save_absence(payload,user,absence_id=None):
require_absence(user,payload.user_id)
with atomic_transaction():
employee_locks([payload.user_id])
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s',(payload.user_id,)):
raise HTTPException(400,'Medarbejderen findes ikke')
old = None
if absence_id:
old = execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,))
if not old:
raise HTTPException(404,'Fravær findes ikke')
require_absence(user,old['user_id'])
if old['user_id'] != payload.user_id:
raise HTTPException(400,'Fravær kan ikke overdrages')
if old['version'] != payload.version:
raise HTTPException(409,'Fravær er ændret')
if execute_query_single('SELECT id FROM tmodule_times WHERE planner_absence_id=%s',(absence_id,)):
raise HTTPException(409,'Fraværet er allerede tidsregistreret')
if not can_see_absence_reason(user,old['user_id']) and (payload.kind is not None or 'note' in payload.model_fields_set):
raise HTTPException(403,'Du må kun ændre perioden, ikke fraværsårsagen')
kind=payload.kind if payload.kind is not None else old['kind'] if old else None
note=payload.note if 'note' in payload.model_fields_set or not old else old['note']
if not execute_query_single('SELECT code FROM planner_activity_types WHERE code=%s AND is_absence AND (active OR %s)',(kind,bool(old and kind==old['kind']))):
raise HTTPException(400,'Ukendt fraværstype')
# The employee advisory lock serializes create/update checks, including concurrent requests.
# Legacy duplicate periods are retained; new/edited periods cannot introduce the same type twice.
if execute_query_single('''SELECT id FROM planner_absences WHERE user_id=%s AND kind=%s
AND deleted_at IS NULL AND id IS DISTINCT FROM %s AND starts_at<%s AND ends_at>%s LIMIT 1''',
(payload.user_id,kind,absence_id,payload.ends_at,payload.starts_at)):
raise HTTPException(409,{'code':'absence_overlap',
'message':'Der findes allerede fravær af denne type i perioden. Ret den eksisterende periode i stedet.'})
values = (payload.user_id,payload.starts_at,payload.ends_at,kind,note)
if absence_id:
row = execute_query_single('UPDATE planner_absences SET user_id=%s,starts_at=%s,ends_at=%s,kind=%s,note=%s,version=version+1 WHERE id=%s RETURNING *',(*values,absence_id))
else:
row = execute_query_single('INSERT INTO planner_absences(user_id,starts_at,ends_at,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s) RETURNING *',(*values,user['id']))
affected = execute_query('SELECT id,sag_id FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s',(payload.user_id,payload.ends_at,payload.starts_at)) or []
audit(user,'absence_update' if old else 'absence_create','absence',row['id'],old,row)
for sag_id in {r['sag_id'] for r in affected if r['sag_id']}:
notify_case(user,sag_id,f'absence:{row["id"]}:{row["version"]}:{sag_id}',
'Fravær påvirker en planlagt aftale.',[payload.user_id])
return {'absence':visible_absence(row,user),'affected_allocations':affected}
@router.post('/absences',status_code=201)
def create_absence(payload:AbsenceInput,user:dict=Depends(get_current_user)):
return save_absence(payload,user)
@router.patch('/absences/{absence_id}')
def update_absence(absence_id:int,payload:AbsenceInput,user:dict=Depends(get_current_user)):
return save_absence(payload,user,absence_id)
@router.delete('/absences/{absence_id}')
def cancel_absence(absence_id:int,version:int,user:dict=Depends(get_current_user)):
with atomic_transaction():
old=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL',(absence_id,))
if not old:
raise HTTPException(404,'Fravær findes ikke')
require_absence(user,old['user_id'])
employee_locks([old['user_id']])
row=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,))
if not row or row['version']!=version:
raise HTTPException(409,'Fraværet er ændret')
if execute_query_single('SELECT id FROM tmodule_times WHERE planner_absence_id=%s',(absence_id,)):
raise HTTPException(409,'Fraværet er allerede tidsregistreret og kan ikke annulleres her')
execute_query('UPDATE planner_absences SET deleted_at=NOW(),version=version+1 WHERE id=%s',(absence_id,),fetch=False)
audit(user,'absence_cancel','absence',absence_id,old,{'cancelled':True})
return {'cancelled':True}
@router.post('/absences/{absence_id}/register')
def register_absence(absence_id:int,version:int,user:dict=Depends(get_current_user)):
"""Explicit, replay-safe conversion to nonbillable actual time."""
with atomic_transaction():
row = execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL',(absence_id,))
if not row:
raise HTTPException(404,'Fravær findes ikke')
require_absence(user,row['user_id'])
employee_locks([row['user_id']])
row=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,))
if not row:
raise HTTPException(409,'Fraværet er ændret')
existing = execute_query('SELECT id FROM tmodule_times WHERE planner_absence_id=%s ORDER BY worked_date',(absence_id,)) or []
if existing:
return {'time_entry_ids':[r['id'] for r in existing],'already_registered':True}
if row['version'] != version:
raise HTTPException(409,'Fravær er ændret')
if _aware(row['ends_at']) > datetime.now(TZ):
raise HTTPException(400,'Fremtidigt fravær kan ikke registreres som afholdt')
if execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s
AND planner_absence_id IS NOT NULL AND planner_absence_id<>%s
AND start_tid<%s AND slut_tid>%s AND status<>'rejected' LIMIT 1''',
(row['user_id'],absence_id,db_utc(row['ends_at']).replace(tzinfo=None),
db_utc(row['starts_at']).replace(tzinfo=None))):
raise HTTPException(409,'Der er allerede registreret fravær i en del af perioden')
# Only scheduled working minutes become actual absence, never nights/weekends.
schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active',(row['user_id'],)) or []
days = absence_work_days(row,schedules)
if not days:
raise HTTPException(400,'Fraværet indeholder ingen planlagt arbejdstid')
name = execute_query_single('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s',(row['user_id'],))['name']
entry_ids = []
for day,start,end,minutes in days:
entry = execute_query_single('''INSERT INTO tmodule_times(description,original_hours,worked_date,user_name,status,
billable,billing_method,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status,medarbejder_id,
aktiv_timer,round_block_min,ikke_placeret,work_type,start_tid,slut_tid,planner_absence_id,planner_activity_code)
VALUES(%s,%s,%s,%s,'pending',false,'draft',%s,0,'manuel','manuel','kladde',%s,false,1,false,'other',%s,%s,%s,%s) RETURNING id''',
('Fravær',minutes/60,day,name,minutes,row['user_id'],
db_utc(start).replace(tzinfo=None),db_utc(end).replace(tzinfo=None),absence_id,row['kind']))
entry_ids.append(entry['id'])
audit(user,'absence_register','absence',absence_id,None,{'time_entry_ids':entry_ids})
return {'time_entry_ids':entry_ids,'already_registered':False}
def absence_work_minutes(absence,schedules):
return sum(row[3] for row in absence_work_days(absence,schedules))
def absence_work_days(absence,schedules):
start,end = _aware(absence['starts_at']).astimezone(TZ),_aware(absence['ends_at']).astimezone(TZ)
day = start.date()
last_day=end.date()
start,end=db_utc(start),db_utc(end)
result = []
while day <= last_day:
total = 0
intervals = []
for schedule in schedules:
if schedule['weekday'] != day.weekday():
continue
if schedule.get('valid_from') and day < schedule['valid_from'] or schedule.get('valid_to') and day > schedule['valid_to']:
continue
a,b = datetime.combine(day,schedule['start_time'],TZ),datetime.combine(day,schedule['end_time'],TZ)
a,b=db_utc(a),db_utc(b)
overlap = max(0,(min(end,b)-max(start,a)).total_seconds()/60)
gross = (b-a).total_seconds()/60
total += round(max(0,overlap-int(schedule['break_minutes'] or 0)) if overlap>=gross else overlap)
if overlap:
intervals.append((max(start,a),min(end,b)))
if total:
result.append((day,min(a for a,b in intervals),max(b for a,b in intervals),total))
day += timedelta(days=1)
return result
@router.get('/activity-types')
def activity_types(user:dict=Depends(get_current_user)):
require_target(user,int(user['id']))
return execute_query('SELECT * FROM planner_activity_types ORDER BY label') or []
@router.get('/schedule')
def schedule(starts_at:datetime,ends_at:datetime,user_id:list[int]=Query(default=[]),user:dict=Depends(get_current_user),include_unassigned:bool=False):
if ends_at<=starts_at or ends_at-starts_at>timedelta(days=370):
raise HTTPException(400,'Ugyldig periode')
ids = sorted(set(user_id or [int(user['id'])]))
for uid in ids:
require_target(user,uid)
bookings = execute_query('''SELECT a.id,a.event_id,a.user_id,a.sag_id,a.starts_at,a.ends_at,a.version,a.locked,
COALESCE(e.title,s.titel,'Optaget') AS title,e.kind,COALESCE(e.description,a.note) AS description,e.version AS event_version,
ARRAY(SELECT p.user_id FROM planner_allocations p WHERE p.event_id=a.event_id AND p.deleted_at IS NULL ORDER BY p.user_id) AS participants
FROM planner_allocations a LEFT JOIN planner_events e ON e.id=a.event_id
LEFT JOIN sag_sager s ON s.id=a.sag_id
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s''',(ids,ends_at,starts_at)) or []
absences = execute_query('''SELECT a.*,EXISTS(SELECT 1 FROM tmodule_times t WHERE t.planner_absence_id=a.id) AS registered
FROM planner_absences a WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s''',(ids,ends_at,starts_at)) or []
absences=[visible_absence(row,user) for row in absences]
quick = execute_query("SELECT id,user_id,description AS title,starts_at,ends_at FROM quick_time_items WHERE state='planned' AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",(ids,ends_at,starts_at)) or []
hidden=hidden_case_ids(user)
include_inbox=include_unassigned and bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.allocate'})
cases=execute_query('''SELECT s.id,s.titel,s.start_date,s.start_time,s.deadline,s.deferred_until,s.updated_at FROM sag_sager s
WHERE s.deleted_at IS NULL AND NOT(s.id=ANY(%s)) AND
(s.ansvarlig_bruger_id=ANY(%s) OR EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id AND a.user_id=ANY(%s))
OR (%s AND NOT EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id)))
AND ((s.start_date BETWEEN %s AND %s) OR (s.deadline BETWEEN %s AND %s) OR (s.deferred_until BETWEEN %s AND %s))''',
(list(hidden),ids,ids,include_inbox,starts_at.date(),ends_at.date(),db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None),db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None))) or []
milestones=[]
for case in cases:
if case['start_date']:
value=datetime.combine(case['start_date'],case['start_time'],TZ).isoformat() if case['start_time'] else case['start_date'].isoformat()
milestones.append({'id':f'start-{case["id"]}','sag_id':case['id'],'title':'Start: '+case['titel'],'start':value,'allDay':not bool(case['start_time']),'kind':'case_start'})
if case['deadline']:
milestones.append({'id':f'deadline-{case["id"]}','sag_id':case['id'],'title':'Deadline: '+case['titel'],
'start':db_utc(case['deadline']).astimezone(TZ).isoformat(),'kind':'deadline'})
if case['deferred_until']:
milestones.append({'id':f'deferred-{case["id"]}','sag_id':case['id'],'title':'Udsat: '+case['titel'],
'start':db_utc(case['deferred_until']).astimezone(TZ).isoformat(),'kind':'deferred'})
versions={case['id']:db_utc(case['updated_at']) for case in cases}
for milestone in milestones:
milestone['case_updated_at']=versions[milestone['sag_id']]
reminders=execute_query('''SELECT r.id,r.sag_id,r.title,COALESCE(r.next_check_at,r.scheduled_at) AS moment
FROM sag_reminders r JOIN sag_sager s ON s.id=r.sag_id WHERE r.deleted_at IS NULL AND r.is_active
AND s.deleted_at IS NULL AND NOT(s.id=ANY(%s))
AND (r.recipient_user_ids && %s::integer[] OR r.created_by_user_id=ANY(%s))
AND COALESCE(r.next_check_at,r.scheduled_at)>=%s AND COALESCE(r.next_check_at,r.scheduled_at)<%s''',
(list(hidden),ids,ids,starts_at,ends_at)) or []
for reminder in reminders:
milestones.append({'id':f'reminder-{reminder["id"]}','sag_id':reminder['sag_id'],
'title':'Påmindelse: '+reminder['title'],'start':_aware(reminder['moment']).isoformat(),'kind':'reminder'})
registered=execute_query('''SELECT t.id,t.sag_id,t.medarbejder_id AS user_id,
COALESCE(s.titel,t.description,'Registreret tid') AS title,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS starts_at,
COALESCE(t.slut_tid,COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)+
make_interval(mins=>GREATEST(COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int,1))) AS ends_at
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
WHERE t.medarbejder_id=ANY(%s) AND t.status<>'rejected' AND t.planner_absence_id IS NULL
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s''',
(ids,db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None))) or []
for entry in registered:
entry['starts_at']=db_utc(entry['starts_at'])
entry['ends_at']=db_utc(entry['ends_at'])
# Converted standalone work is already represented by its canonical time entry above.
external=execute_query('SELECT id,user_id,starts_at,ends_at FROM planner_external_busy WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s',(ids,ends_at,starts_at)) or []
mark_conflicts(bookings,absences+quick+external)
schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
for row in bookings:
row['outside_work_hours']=outside_work_hours(row['user_id'],row['starts_at'],row['ends_at'],schedules)
return filter_data({'bookings':bookings,'absences':absences,'standalone_plans':quick,
'milestones':milestones,'registered':registered,'external_busy':external},hidden)
def can_view_absence_report(user):
return bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_report'})
def require_absence_report(user):
from .router import _require_view
_require_view(user)
if not can_view_absence_report(user):
raise HTTPException(403,'Du har ikke adgang til fraværsrapporter')
def merged_absence_totals(rows,schedules,start,end):
"""Union overlapping absence before counting scheduled minutes and day fractions."""
spans=sorted((max(_aware(r['starts_at']),start),min(_aware(r['ends_at']),end)) for r in rows)
merged=[]
for a,b in spans:
if b<=a:
continue
if merged and a<=merged[-1][1]:
merged[-1]=(merged[-1][0],max(b,merged[-1][1]))
else:
merged.append((a,b))
minutes_by_day={}
for a,b in merged:
for day,_,_,minutes in absence_work_days({'starts_at':a,'ends_at':b},schedules):
minutes_by_day[day]=minutes_by_day.get(day,0)+minutes
days=0
for day,minutes in minutes_by_day.items():
norm=absence_work_minutes({'starts_at':datetime.combine(day,time.min,TZ),
'ends_at':datetime.combine(day+timedelta(days=1),time.min,TZ)},schedules)
minutes_by_day[day]=min(minutes,norm)
days+=min(1,minutes/norm) if norm else 0
return {'planned_minutes':sum(minutes_by_day.values()),'planned_days':round(days,2)}
@router.get('/absence-report')
def absence_report(start_date:date,end_date:date,user_id:int|None=None,
limit:int=Query(50,ge=1,le=500),offset:int=Query(0,ge=0),
user:dict=Depends(get_current_user),group_by:Literal['date','type']='date'):
require_absence_report(user)
if end_date<start_date or (end_date-start_date).days>366:
raise HTTPException(400,'Vælg en periode på højst 367 dage')
from .router import _can_view_target
employees=execute_query('SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users ORDER BY name,user_id') or []
if user_id is not None:
require_target(user,user_id)
employees=[r for r in employees if r['id']==user_id]
else:
employees=[r for r in employees if _can_view_target(user,r['id'])]
# Reporting grants no additional employee scope or case access.
for employee in employees:
require_target(user,employee['id'])
ids=[r['id'] for r in employees]
names={r['id']:r['name'] for r in employees}
start=datetime.combine(start_date,time.min,TZ)
end=datetime.combine(end_date+timedelta(days=1),time.min,TZ)
rows=execute_query("""SELECT a.*,COALESCE(t.minutes,0)::int AS actual_minutes,
COALESCE(t.entries,0)::int AS actual_entries,COALESCE(k.label,a.kind) AS kind_label
FROM planner_absences a LEFT JOIN planner_activity_types k ON k.code=a.kind
LEFT JOIN (SELECT planner_absence_id,SUM(COALESCE(faktisk_tid_min,
ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes,COUNT(*) AS entries
FROM tmodule_times WHERE planner_absence_id IS NOT NULL AND status<>'rejected'
AND medarbejder_id=ANY(%s) AND worked_date BETWEEN %s AND %s GROUP BY planner_absence_id) t
ON t.planner_absence_id=a.id
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND
((a.starts_at<%s AND a.ends_at>%s) OR t.entries>0)
ORDER BY a.starts_at,a.user_id,a.id""",(ids,start_date,end_date,ids,end,start)) or []
schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
summaries=[]
for employee in employees:
own=[r for r in rows if r['user_id']==employee['id']]
norm=[r for r in schedules if r['user_id']==employee['id']]
summaries.append(dict(user_id=employee['id'],name=employee['name'],norm_configured=bool(norm),
actual_minutes=sum(r['actual_minutes'] for r in own),periods=len(own),
**merged_absence_totals(own,norm,start,end)))
# Mask before grouping/sorting: hidden reasons must never form separate groups.
by_type={}
for row in rows:
kind=row['kind'] if can_see_absence_reason(user,row['user_id']) else 'unavailable'
label=row['kind_label'] if kind!='unavailable' else 'Fravær'
by_type.setdefault(kind,{'kind':kind,'kind_label':label,'rows':[]})['rows'].append(row)
type_summary=[]
for group in sorted(by_type.values(),key=lambda g:(g['kind_label'].casefold(),g['kind'])):
members=[]
for employee in employees:
own=[r for r in group['rows'] if r['user_id']==employee['id']]
if not own:
continue
norm=[r for r in schedules if r['user_id']==employee['id']]
members.append(dict(user_id=employee['id'],name=employee['name'],norm_configured=bool(norm),
actual_minutes=sum(r['actual_minutes'] for r in own),periods=len(own),
**merged_absence_totals(own,norm,start,end)))
type_summary.append(dict(kind=group['kind'],kind_label=group['kind_label'],employees=members,
periods=len(group['rows']),norm_configured=all(r['norm_configured'] for r in members),
planned_minutes=sum(r['planned_minutes'] for r in members),
planned_days=round(sum(r['planned_days'] for r in members),2),
actual_minutes=sum(r['actual_minutes'] for r in members)))
if group_by=='type':
rows=[row for group in sorted(by_type.values(),key=lambda g:(g['kind_label'].casefold(),g['kind']))
for row in sorted(group['rows'],key=lambda r:(names[r['user_id']].casefold(),r['user_id'],r['starts_at'],r['id']))]
items=[]
for row in rows[offset:offset+limit]:
safe=visible_absence(row,user)
norm=[r for r in schedules if r['user_id']==row['user_id']]
values=merged_absence_totals([row],norm,start,end)
# Private notes never belong in a report or its export.
items.append(dict(id=row['id'],user_id=row['user_id'],name=names[row['user_id']],
kind=safe['kind'],kind_label=row['kind_label'] if safe['kind']!='unavailable' else 'Fravær',
starts_at=row['starts_at'],ends_at=row['ends_at'],actual_minutes=row['actual_minutes'],
registered=bool(row['actual_entries']),norm_configured=bool(norm),**values))
return {'items':items,'total':len(rows),'limit':limit,'offset':offset,'summary':summaries,'type_summary':type_summary,
'employees':employees,'start_date':start_date,'end_date':end_date,
'totals':{'planned_minutes':sum(r['planned_minutes'] for r in summaries),
'planned_days':round(sum(r['planned_days'] for r in summaries),2),
'actual_minutes':sum(r['actual_minutes'] for r in summaries)}}

View File

@ -0,0 +1,151 @@
"""One interval-based capacity calculation for the unified work surface."""
from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single
from app.core.case_visibility import require_case,hidden_case_ids,filter_data
from .router import TZ, _aware, _merge_minutes
from .work import require_target, remaining, FINISHED, require_schema,db_utc
router = APIRouter(dependencies=[Depends(require_schema)])
def working_capacity(schedules, occupied, start, end):
normal = busy = 0
day = start.astimezone(TZ).date()
last_day=end.astimezone(TZ).date()
start,end=start.astimezone(timezone.utc),end.astimezone(timezone.utc)
occupied=[(a.astimezone(timezone.utc),b.astimezone(timezone.utc)) for a,b in occupied]
while day <= last_day:
for schedule in schedules:
if schedule['weekday'] != day.weekday():
continue
if schedule.get('valid_from') and day < schedule['valid_from'] or schedule.get('valid_to') and day > schedule['valid_to']:
continue
a,b = datetime.combine(day,schedule['start_time'],TZ),datetime.combine(day,schedule['end_time'],TZ)
a,b=a.astimezone(timezone.utc),b.astimezone(timezone.utc)
lo,hi = max(a,start),min(b,end)
if hi <= lo:
continue
gross = (b-a).total_seconds()/60
fraction = max(0,gross-int(schedule['break_minutes'] or 0))/gross
net=round((hi-lo).total_seconds()/60*fraction)
normal += net
busy += min(net,_merge_minutes(occupied,lo,hi))
day += timedelta(days=1)
return {'normal_minutes':normal,'busy_minutes':busy,'free_minutes':max(0,normal-busy)}
@router.get('/capacity')
def capacity(starts_at:datetime,ends_at:datetime,user_id:list[int]=Query(default=[]),user:dict=Depends(get_current_user)):
start,end = _aware(starts_at),_aware(ends_at)
if end<=start or end-start>timedelta(days=370):
raise HTTPException(400,'Ugyldig periode')
ids = sorted(set(user_id or [int(user['id'])]))
for uid in ids:
require_target(user,uid)
schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
occupied = execute_query('''SELECT user_id,starts_at,ends_at FROM planner_allocations
WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
UNION ALL SELECT user_id,starts_at,ends_at FROM planner_absences
WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
UNION ALL SELECT user_id,starts_at,ends_at FROM planner_external_busy
WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s
UNION ALL SELECT user_id,starts_at,ends_at FROM quick_time_items
WHERE user_id=ANY(%s) AND state='planned' AND starts_at<%s AND ends_at>%s''',
(ids,end,start,ids,end,start,ids,end,start,ids,end,start)) or []
assignments = execute_query('''SELECT a.*,s.deadline,s.estimated_minutes AS case_estimated_minutes,
COALESCE(t.minutes,0) AS actual_minutes,COALESCE(p.minutes,0) AS future_planned_minutes,p.after_deadline
FROM sag_case_assignees a JOIN sag_sager s ON s.id=a.sag_id
LEFT JOIN LATERAL (SELECT SUM(COALESCE(faktisk_tid_min,ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes
FROM tmodule_times WHERE sag_id=a.sag_id AND medarbejder_id=a.user_id AND status<>'rejected') t ON TRUE
LEFT JOIN LATERAL (SELECT SUM(EXTRACT(EPOCH FROM (ends_at-GREATEST(starts_at,NOW())))/60)::int AS minutes,
BOOL_OR(ends_at>(s.deadline AT TIME ZONE 'UTC')) AS after_deadline
FROM planner_allocations WHERE sag_id=a.sag_id AND user_id=a.user_id AND deleted_at IS NULL AND ends_at>NOW()) p ON TRUE
WHERE a.user_id=ANY(%s) AND s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s) AND a.work_status<>'done' ''',(ids,list(FINISHED))) or []
names = {r['id']:r['name'] for r in execute_query('SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users WHERE user_id=ANY(%s)',(ids,)) or []}
items = []
for uid in ids:
rows = [r for r in assignments if r['user_id']==uid]
configured=any(r['user_id']==uid for r in schedules)
values = working_capacity([r for r in schedules if r['user_id']==uid],
[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in occupied if r['user_id']==uid],start,end)
queued = sum(max(0,remaining(r)-r['future_planned_minutes']) for r in rows if remaining(r) is not None)
risks=[]
for row in rows:
if not row['deadline']:
continue
deadline=db_utc(row['deadline'])
if deadline>end:
continue
horizon_start=max(start,datetime.now(TZ))
free=working_capacity([r for r in schedules if r['user_id']==uid],
[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in occupied if r['user_id']==uid],horizon_start,deadline)['free_minutes'] if deadline>horizon_start else 0
rest=remaining(row)
kind=('overdue' if deadline<datetime.now(TZ) else 'booking_after_deadline' if row['after_deadline'] else
'unallocated_estimate' if rest is None else 'insufficient_capacity' if configured and max(0,rest-row['future_planned_minutes'])>free else None)
if kind:
risks.append({'sag_id':row['sag_id'],'kind':kind,'deadline':deadline,'available_minutes':free})
items.append(dict(values,user_id=uid,name=names.get(uid,''),norm_configured=configured,queue_minutes=queued,
overload_minutes=max(0,queued-values['free_minutes']) if configured else None,
unestimated_count=sum(remaining(r) is None for r in rows),deadline_risks=risks))
return filter_data({'items':items,'starts_at':start,'ends_at':end,'queue_horizon':'all_open_work'},hidden_case_ids(user))
@router.get('/unassigned')
def unassigned(limit:int=Query(100,ge=1,le=500),offset:int=Query(0,ge=0),user:dict=Depends(get_current_user),q:str=''):
require_target(user,int(user['id']))
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
raise HTTPException(403,'Du må ikke se ikke-allokerede sager')
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
search=q.strip()[:200]
rows=execute_query('''SELECT s.id,s.titel,s.priority,s.deadline,s.start_date,s.start_time,s.estimated_minutes,s.updated_at AS case_updated_at,
c.name AS customer_name,COALESCE(u.full_name,u.username) AS responsible_name
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s)
AND NOT(s.id=ANY(%s))
AND NOT EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id)
AND (%s='' OR CONCAT_WS(' ',s.id::text,s.titel,c.name,u.full_name,u.username) ILIKE %s)
ORDER BY s.deadline NULLS LAST,s.id LIMIT %s OFFSET %s''',
(list(FINISHED|set(_fetch_closed_case_statuses())),list(hidden_case_ids(user)),search,'%'+search+'%',limit+1,offset)) or []
for row in rows:
row['case_updated_at']=db_utc(row['case_updated_at'])
if row['deadline']:
row['deadline']=db_utc(row['deadline']).astimezone(TZ)
return {'items':rows[:limit],'has_more':len(rows)>limit,'offset':offset,'limit':limit}
@router.get('/cases/{sag_id}/summary')
def case_summary(sag_id:int,include_children:bool=True,user:dict=Depends(get_current_user),scope:Literal['own','children','all']|None=None):
require_case(user,sag_id)
require_target(user,int(user['id']))
if not execute_query_single('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)):
raise HTTPException(404,'Sagen findes ikke')
# UNION deduplicates IDs, including repeated relations and accidental cycles.
selected=scope or ('all' if include_children else 'own')
result=execute_query_single('''WITH RECURSIVE family(id) AS (
SELECT %s::integer UNION SELECT r.målsag_id FROM sag_relationer r JOIN family f ON f.id=r.kilde_sag_id
WHERE %s AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL
), cases AS (SELECT s.* FROM sag_sager s JOIN family f ON f.id=s.id
WHERE s.deleted_at IS NULL AND NOT(s.id=ANY(%s)) AND (%s OR s.id<>%s)),
actual AS (SELECT t.sag_id,SUM(COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))) AS minutes
FROM tmodule_times t JOIN cases c ON c.id=t.sag_id WHERE t.status<>'rejected' GROUP BY t.sag_id)
SELECT (SELECT COUNT(*) FROM cases) AS case_count,
(SELECT SUM(estimated_minutes) FROM cases) AS estimated_minutes,
(SELECT COUNT(*) FROM cases WHERE estimated_minutes IS NULL) AS unestimated_cases,
(SELECT COALESCE(SUM(minutes),0) FROM actual) AS actual_minutes,
(SELECT SUM(GREATEST(0,c.estimated_minutes-COALESCE(a.minutes,0)))
FROM cases c LEFT JOIN actual a ON a.sag_id=c.id WHERE c.estimated_minutes IS NOT NULL) AS remaining_minutes,
(SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60),0)::int
FROM planner_allocations a JOIN cases c ON c.id=a.sag_id WHERE a.deleted_at IS NULL) AS planned_person_minutes,
(SELECT MIN(a.starts_at) FROM planner_allocations a JOIN cases c ON c.id=a.sag_id
WHERE a.deleted_at IS NULL AND a.ends_at>NOW()) AS next_activity,
(SELECT MAX(deadline) FROM cases) AS latest_deadline''',
(sag_id,selected!='own',list(hidden_case_ids(user)),selected!='children',sag_id))
if result and result['latest_deadline']:
result['latest_deadline']=db_utc(result['latest_deadline']).astimezone(TZ)
return result

View File

@ -0,0 +1,37 @@
"""Idempotent in-app deadline checks through Hub's existing reminder scheduler."""
from app.core.database import atomic_transaction,execute_query_single,execute_query
from .work import FINISHED
def run_planning_checks():
ready=execute_query_single("""SELECT EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_name='planner_settings' AND column_name='deadline_warning_days') AS ready""")
if not ready or not ready['ready']:
return 0
config=execute_query_single('SELECT deadline_warning_days FROM planner_settings WHERE id=1') or {}
days=int(config.get('deadline_warning_days',0))
if not days:
return 0
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
closed=FINISHED|set(_fetch_closed_case_statuses())
with atomic_transaction():
rows=execute_query('''WITH candidates AS (
SELECT s.id,s.deadline,EXISTS(SELECT 1 FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id
WHERE a.sag_id=s.id AND u.is_active) AS allocated,
ARRAY(SELECT DISTINCT u.user_id FROM users u WHERE u.is_active AND
(u.user_id=s.ansvarlig_bruger_id OR EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id AND a.user_id=u.user_id)
OR (NOT EXISTS(SELECT 1 FROM sag_case_assignees a JOIN users au ON au.user_id=a.user_id WHERE a.sag_id=s.id AND au.is_active)
AND (u.is_superadmin OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=u.user_id AND p.code IN ('planner.admin','planner.allocate')))))) AS recipients
FROM sag_sager s WHERE s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s)
AND (s.deadline AT TIME ZONE 'UTC') BETWEEN NOW()-INTERVAL '1 day' AND NOW()+make_interval(days=>%s)
) INSERT INTO sag_reminders(sag_id,title,message,event_type,trigger_type,trigger_config,
recipient_user_ids,notify_mattermost,notify_email,notify_frontend,override_user_preferences,
recurrence_type,scheduled_at,next_check_at)
SELECT id,CASE WHEN allocated THEN 'Deadline nærmer sig' ELSE 'Deadline: medarbejder mangler' END,
'Kontrollér deadline, kapacitet og arbejdsfordeling.','deadline','time_based',
jsonb_build_object('planner_key','deadline:'||id::text||':'||deadline::text||':'||allocated::text||':'||
TO_CHAR(NOW() AT TIME ZONE 'Europe/Copenhagen','YYYY-MM-DD')),
recipients,FALSE,FALSE,TRUE,TRUE,'once',NOW(),NOW()
FROM candidates WHERE cardinality(recipients)>0 ORDER BY id ON CONFLICT DO NOTHING RETURNING id''',(list(closed),days)) or []
return len(rows)

View File

@ -0,0 +1,136 @@
"""Planner settings and per-employee work schedules use the existing tables."""
from datetime import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from app.core.auth_dependencies import get_current_user
from app.core.database import atomic_transaction,execute_query,execute_query_single
from .work import require_schema,require_target,audit
router=APIRouter(dependencies=[Depends(require_schema)])
def require_admin(user):
if not (user.get('is_superadmin') or 'planner.admin' in set(user.get('permissions') or [])):
raise HTTPException(403,'Du må ikke ændre planlægningsindstillinger')
class ConfigurationInput(BaseModel):
appointment_warning_minutes:int=Field(default=30,ge=0,le=1440)
deadline_warning_days:int=Field(default=1,ge=0,le=30)
class TeamScopeInput(BaseModel):
user_ids:list[int]=Field(default_factory=list,max_length=1000)
@router.get('/team-scope/{editor}')
def team_scope(editor:int,user:dict=Depends(get_current_user)):
require_admin(user)
return {'user_ids':[r['target_user_id'] for r in execute_query('SELECT target_user_id FROM planner_team_scope WHERE editor_user_id=%s ORDER BY target_user_id',(editor,)) or []]}
@router.put('/team-scope/{editor}')
def update_team_scope(editor:int,payload:TeamScopeInput,user:dict=Depends(get_current_user)):
require_admin(user)
ids=sorted(set(payload.user_ids))
with atomic_transaction():
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active FOR UPDATE',(editor,)):
raise HTTPException(400,'Vælg en aktiv medarbejder')
if len(execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) AND is_active',(ids,)) or [])!=len(ids):
raise HTTPException(400,'Vælg aktive medarbejdere')
old=team_scope(editor,user)
execute_query('DELETE FROM planner_team_scope WHERE editor_user_id=%s',(editor,),fetch=False)
for uid in ids:
execute_query('INSERT INTO planner_team_scope(editor_user_id,target_user_id) VALUES(%s,%s)',(editor,uid),fetch=False)
audit(user,'team_scope_update','team_scope',editor,old,{'user_ids':ids})
return {'user_ids':ids}
@router.get('/configuration')
def configuration(user:dict=Depends(get_current_user)):
require_target(user,int(user['id']))
row=execute_query_single('SELECT * FROM planner_settings WHERE id=1') or {}
row['can_manage']=bool(user.get('is_superadmin') or 'planner.admin' in set(user.get('permissions') or []))
return row
@router.put('/configuration')
def update_configuration(payload:ConfigurationInput,user:dict=Depends(get_current_user)):
require_admin(user)
with atomic_transaction():
old=execute_query_single('SELECT * FROM planner_settings WHERE id=1 FOR UPDATE')
row=execute_query_single('UPDATE planner_settings SET appointment_warning_minutes=%s,deadline_warning_days=%s,updated_by=%s,updated_at=NOW() WHERE id=1 RETURNING *',(payload.appointment_warning_minutes,payload.deadline_warning_days,user['id']))
audit(user,'settings_update','settings',1,old,row)
return row
class ActivityTypeInput(BaseModel):
label:str=Field(min_length=1,max_length=80)
active:bool=True
is_absence:bool=True
@router.put('/activity-types/{code}')
def update_activity_type(code:str,payload:ActivityTypeInput,user:dict=Depends(get_current_user)):
require_admin(user)
import re
if not code or len(code)>40 or not payload.label.strip():
raise HTTPException(400,'Ugyldig kode eller navn')
with atomic_transaction():
old=execute_query_single('SELECT * FROM planner_activity_types WHERE code=%s FOR UPDATE',(code,))
if not old and not re.fullmatch('[a-z][a-z_]{0,39}',code):
raise HTTPException(400,'Ugyldig kode til ny type')
if old and old['is_absence']!=payload.is_absence and execute_query_single('SELECT id FROM planner_absences WHERE kind=%s LIMIT 1',(code,)):
raise HTTPException(409,'En type med registreret fravær kan ikke ændres til en anden kategori. Opret en ny type.')
row=execute_query_single('''INSERT INTO planner_activity_types(code,label,active,is_absence) VALUES(%s,%s,%s,%s)
ON CONFLICT(code) DO UPDATE SET label=EXCLUDED.label,active=EXCLUDED.active,is_absence=EXCLUDED.is_absence RETURNING *''',
(code,payload.label.strip(),payload.active,payload.is_absence))
audit(user,'type_update','activity_type',0,old,row)
return row
class WorkDay(BaseModel):
weekday:int=Field(ge=0,le=6)
start_time:time
end_time:time
break_minutes:int=Field(default=0,ge=0,le=1440)
@model_validator(mode='after')
def valid(self):
minutes=(self.end_time.hour*60+self.end_time.minute)-(self.start_time.hour*60+self.start_time.minute)
if minutes<=self.break_minutes:
raise ValueError('Arbejdsperioden skal være længere end pausen')
return self
class ScheduleInput(BaseModel):
days:list[WorkDay]=Field(max_length=7)
@model_validator(mode='after')
def unique_days(self):
if len({d.weekday for d in self.days})!=len(self.days):
raise ValueError('Angiv kun én arbejdsperiode pr. ugedag')
return self
@router.get('/work-schedules/{target}')
def work_schedules(target:int,user:dict=Depends(get_current_user)):
require_target(user,target)
return execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active ORDER BY weekday',(target,)) or []
@router.put('/work-schedules/{target}')
def update_work_schedules(target:int,payload:ScheduleInput,user:dict=Depends(get_current_user)):
require_admin(user)
require_target(user,target,True)
with atomic_transaction():
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE',(target,))
old=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active FOR UPDATE',(target,)) or []
execute_query('UPDATE planner_work_schedules SET active=false WHERE user_id=%s AND active',(target,),fetch=False)
for day in payload.days:
execute_query('INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,%s,%s,%s,%s)',
(target,day.weekday,day.start_time,day.end_time,day.break_minutes),fetch=False)
audit(user,'schedule_update','work_schedule',target,old,payload.model_dump(mode='json'))
return work_schedules(target,user)

View File

@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor, Json
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from .providers import busy_time_provider
from app.core.case_visibility import require_case
router = APIRouter(prefix="/planner")
TZ = ZoneInfo("Europe/Copenhagen")
@ -69,12 +70,25 @@ def _can_edit(user: dict, target_id: int) -> bool:
return True
if "planner.edit_team" not in _permissions(user):
return False
if '_planner_team_scope_ids' in user:
return target_id in user['_planner_team_scope_ids']
return bool(execute_query_single(
"SELECT 1 FROM planner_team_scope WHERE editor_user_id=%s AND target_user_id=%s",
(user["id"], target_id),
))
def _can_view_target(user:dict,target_id:int)->bool:
if int(user['id'])==target_id or user.get('is_superadmin') or 'planner.admin' in _permissions(user):
return True
if not (_permissions(user) & {'planner.edit_team','planner.view_team'}):
return False
if '_planner_team_scope_ids' not in user:
user['_planner_team_scope_ids']={r['target_user_id'] for r in execute_query(
'SELECT target_user_id FROM planner_team_scope WHERE editor_user_id=%s',(user['id'],)) or []}
return target_id in user['_planner_team_scope_ids']
def _require_view(user: dict):
if not _can_view(user):
raise HTTPException(403, "Du har ikke adgang til Planlæggeren")
@ -85,6 +99,8 @@ def _aware(value: datetime) -> datetime:
def _minutes(start, end) -> int:
if start.tzinfo is not None and end.tzinfo is not None:
start,end=start.astimezone(timezone.utc),end.astimezone(timezone.utc)
return max(0, round((end - start).total_seconds() / 60))
@ -114,7 +130,7 @@ def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, alloc
(user_ids, ends_at.date(), starts_at.date()),
) or []
absences = execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
"SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s",
(user_ids, ends_at, starts_at),
) or []
external = busy_time_provider.get_busy(user_ids, starts_at, ends_at)
@ -146,24 +162,24 @@ def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, alloc
if row:
day_start = datetime.combine(current, row["start_time"], TZ)
day_end = datetime.combine(current, row["end_time"], TZ)
gross = _minutes(day_start, day_end)
unavailable = _merge_minutes(
[( _aware(r["starts_at"]), _aware(r["ends_at"]) ) for r in absences + external if r["user_id"] == uid],
day_start, day_end,
)
available = max(0, gross - int(row["break_minutes"] or 0) - unavailable)
from .capacity import working_capacity
blocked=[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in absences+external if r['user_id']==uid]
periods=[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in allocations if r['user_id']==uid]
period_start,period_end=max(day_start,starts_at),min(day_end,ends_at)
blocked_capacity=working_capacity([row],blocked,period_start,period_end)
combined_capacity=working_capacity([row],blocked+periods,period_start,period_end)
available=blocked_capacity['free_minutes']
allocated=combined_capacity['busy_minutes']-blocked_capacity['busy_minutes']
else:
day_start = datetime.combine(current, time(0), TZ)
day_end = day_start + timedelta(days=1)
available = 0
allocated = _sum_minutes(
[(_aware(r["starts_at"]), _aware(r["ends_at"])) for r in allocations if r["user_id"] == uid],
day_start, day_end,
)
allocated = _merge_minutes(
[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in allocations if r['user_id']==uid],day_start,day_end)
percent = round(allocated * 100 / available) if available else (100 if allocated else 0)
registered_minutes = sum(
int(r.get("minutes") or 0) for r in registered
if r["user_id"] == uid and _aware(r["registered_at"]).astimezone(TZ).date() == current
if r["user_id"] == uid and (r['registered_at'].replace(tzinfo=timezone.utc) if r['registered_at'].tzinfo is None else r['registered_at']).astimezone(TZ).date() == current
)
state = "gray" if available == 0 else (
"red" if percent >= settings["full_percent"] else
@ -206,11 +222,13 @@ def _serialize(row: dict | None):
def metadata(current_user: dict = Depends(get_current_user)):
_require_view(current_user)
users = execute_query("SELECT user_id AS id, COALESCE(full_name, username) AS name FROM users WHERE is_active ORDER BY name") or []
users=[row for row in users if _can_view_target(current_user,row['id'])]
settings = execute_query_single("SELECT warning_percent, full_percent, default_allocation_minutes FROM planner_settings WHERE id=1")
departments = execute_query("SELECT id, name FROM groups ORDER BY name") or []
statuses = busy_time_provider.statuses([r["id"] for r in users])
editable_user_ids = [r["id"] for r in users if _can_edit(current_user, r["id"])]
return {"users": users, "departments": departments, "settings": settings, "integration_statuses": statuses,
from .calendar import can_view_absence_report
return {"can_view_absence_report":can_view_absence_report(current_user),"users": users, "departments": departments, "settings": settings, "integration_statuses": statuses,
"current_user_id": current_user["id"], "permissions": list(_permissions(current_user)),
"editable_user_ids": editable_user_ids}
@ -245,7 +263,7 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes,
BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned,
MAX(a.ends_at) FILTER (WHERE a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned_until
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
LEFT JOIN groups department ON department.id=s.assigned_group_id
LEFT JOIN LATERAL (
@ -257,6 +275,9 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
WHERE s.deleted_at IS NULL
AND NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)"""
params: list = [list(CLOSED_STATUSES), list(CLOSED_STATUSES)]
from app.core.case_visibility import hidden_case_ids
sql += ' AND NOT(s.id=ANY(%s))'
params.append(list(hidden_case_ids(current_user)))
if q:
sql += " AND (s.id::text ILIKE %s OR s.titel ILIKE %s OR c.name ILIKE %s OR COALESCE(u.full_name,u.username,'') ILIKE %s)"
params += [f"%{q}%"] * 4
@ -397,10 +418,13 @@ def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(de
_require_view(current_user)
if ends_at <= starts_at or ends_at - starts_at > timedelta(days=370): raise HTTPException(400, "Ugyldig periode")
ids = user_id or [current_user["id"]]
if any(not _can_view_target(current_user,int(uid)) for uid in ids):
raise HTTPException(403,'Du har ikke adgang til disse medarbejderes planer')
allocations = execute_query(
"""SELECT a.*, s.titel AS case_title, s.priority::text AS priority, c.name AS customer_name,
"""SELECT a.*, COALESCE(e.title,s.titel,'Aftale') AS case_title, s.priority::text AS priority, COALESCE(c.name,'Intern aftale') AS customer_name,
COALESCE(u.full_name,u.username) AS user_name
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id JOIN customers c ON c.id=s.customer_id
FROM planner_allocations a LEFT JOIN sag_sager s ON s.id=a.sag_id LEFT JOIN customers c ON c.id=s.customer_id
LEFT JOIN planner_events e ON e.id=a.event_id
JOIN users u ON u.user_id=a.user_id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s)
AND a.starts_at<%s AND a.ends_at>%s ORDER BY a.starts_at""", (ids, ends_at, starts_at)) or []
time_entries = execute_query(
@ -428,7 +452,23 @@ def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(de
COALESCE(u.full_name,u.username) AS user_name
FROM quick_time_items q JOIN users u ON u.user_id=q.user_id LEFT JOIN customers c ON c.id=q.customer_id
WHERE q.state='planned' AND q.user_id=ANY(%s) AND q.starts_at<%s AND q.ends_at>%s""",(ids,ends_at,starts_at)) or []
return {"allocations": allocations, "standalone_plans": standalone_plans, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
for entry in time_entries:
for key in ('starts_at','ends_at'):
if entry.get(key) and entry[key].tzinfo is None:
entry[key]=entry[key].replace(tzinfo=timezone.utc)
# Capacity already subtracts absence; return the same periods for visible calendar blocks.
# Send no private notes, and mask reasons before exposing labels to the team view.
from .calendar import can_see_absence_reason
absences=execute_query("""SELECT a.id,a.user_id,a.starts_at,a.ends_at,a.kind,
COALESCE(k.label,'Fravær') AS kind_label FROM planner_absences a
LEFT JOIN planner_activity_types k ON k.code=a.kind
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s
ORDER BY a.starts_at,a.id""",(ids,ends_at,starts_at)) or []
for absence in absences:
if not can_see_absence_reason(current_user,absence['user_id']):
absence['kind']='unavailable'
absence['kind_label']='Fravær'
return {"absences":absences,"allocations": allocations, "standalone_plans": standalone_plans, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
"time_entries": time_entries,
"external_busy": busy_time_provider.get_busy(ids, starts_at, ends_at),
"integration_statuses": busy_time_provider.statuses(ids)}
@ -439,6 +479,7 @@ def create_allocation(payload: AllocationInput, current_user: dict = Depends(get
if not _can_edit(current_user, payload.user_id): raise HTTPException(403, "Du må ikke redigere denne medarbejders plan")
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s", (payload.sag_id,))
if not case or case["deleted_at"] or str(case["status"]).lower() in CLOSED_STATUSES: raise HTTPException(409, "Lukkede eller arkiverede sager skal genåbnes før planlægning")
require_case(current_user,payload.sag_id)
conflict, details = _conflicts(payload.user_id, payload.starts_at, payload.ends_at)
if conflict != "none" and not payload.confirm_conflicts: raise HTTPException(409, detail={"message": "Tiden overlapper eller overbooker", "conflict_status": conflict, **details})
conn = get_db_connection()
@ -453,17 +494,31 @@ def create_allocation(payload: AllocationInput, current_user: dict = Depends(get
finally: release_db_connection(conn)
def _lock_booking_parent(cursor,allocation_id):
"""Use the same parent-before-participant lock order as the shared event API."""
cursor.execute('SELECT event_id FROM planner_allocations WHERE id=%s',(allocation_id,))
row=cursor.fetchone()
if row and row.get('event_id'):
cursor.execute('SELECT id FROM planner_events WHERE id=%s FOR UPDATE',(row['event_id'],))
@router.patch("/allocations/{allocation_id}")
def update_allocation(allocation_id: int, payload: AllocationUpdate, current_user: dict = Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_lock_booking_parent(cur,allocation_id)
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
if not old: raise HTTPException(404,"Allokeringen findes ikke")
if old.get('sag_id'): require_case(current_user,old['sag_id'])
if not _can_edit(current_user,old['user_id']): raise HTTPException(403,'Du må ikke redigere denne medarbejders plan')
target=payload.user_id or old["user_id"]
if not _can_edit(current_user,target): raise HTTPException(403,"Du må ikke redigere denne medarbejders plan")
if old["version"] != payload.version: raise HTTPException(409,detail={"message":"Allokeringen er ændret af en anden bruger","current":_serialize(old)})
moving = bool({"user_id", "starts_at", "ends_at"}.intersection(payload.model_fields_set))
if old.get('event_id') and (moving or 'note' in payload.model_fields_set):
cur.execute('SELECT COUNT(*) AS count FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL',(old['event_id'],))
if cur.fetchone()['count']>1: raise HTTPException(409,'Fælles aftaler skal ændres fra den samlede kalender')
if old.get("locked") and moving and payload.locked is not False:
raise HTTPException(409, "Tidspunktet er låst. Fjern låsen før blokken flyttes eller ændres")
start=payload.starts_at or old["starts_at"]; end=payload.ends_at or old["ends_at"]
@ -487,11 +542,16 @@ def delete_allocation(allocation_id:int, version:int, current_user:dict=Depends(
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_lock_booking_parent(cur,allocation_id)
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
if not old: raise HTTPException(404,"Allokeringen findes ikke")
if old.get('sag_id'): require_case(current_user,old['sag_id'])
if not _can_edit(current_user,old["user_id"]): raise HTTPException(403,"Du må ikke slette denne allokering")
if old["version"]!=version: raise HTTPException(409,"Allokeringen er ændret af en anden bruger")
if old.get("locked"): raise HTTPException(409,"Tidspunktet er låst. Fjern låsen før blokken slettes")
if old.get('event_id'):
cur.execute('SELECT COUNT(*) AS count FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL',(old['event_id'],))
if cur.fetchone()['count']>1: raise HTTPException(409,'Fælles aftaler skal annulleres fra den samlede kalender')
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s AND version=%s RETURNING *",(current_user["id"],allocation_id,version)); row=cur.fetchone()
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'deleted',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
conn.commit(); return {"deleted":True,"allocation":row}
@ -506,11 +566,16 @@ def undo_allocation(allocation_id: int, version: int, current_user: dict = Depen
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_lock_booking_parent(cur,allocation_id)
cur.execute("SELECT * FROM planner_allocations WHERE id=%s FOR UPDATE", (allocation_id,))
current = cur.fetchone()
if not current: raise HTTPException(404, "Allokeringen findes ikke")
if current.get('sag_id'): require_case(current_user,current['sag_id'])
if not _can_edit(current_user, current["user_id"]): raise HTTPException(403, "Du må ikke fortryde denne ændring")
if current["version"] != version: raise HTTPException(409, "Kan ikke fortryde: allokeringen er ændret siden")
if current.get('event_id'):
cur.execute('SELECT COUNT(*) AS count FROM planner_allocations WHERE event_id=%s',(current['event_id'],))
if cur.fetchone()['count']>1: raise HTTPException(409,'Fælles aftaler skal ændres fra den samlede kalender')
cur.execute("SELECT * FROM planner_allocation_history WHERE allocation_id=%s ORDER BY id DESC LIMIT 1", (allocation_id,))
history = cur.fetchone()
if not history: raise HTTPException(409, "Der er ingen ændring at fortryde")
@ -535,6 +600,7 @@ def undo_allocation(allocation_id: int, version: int, current_user: dict = Depen
def copy_allocation(allocation_id:int, starts_at:datetime,user_id:int|None=None,confirm_conflicts:bool=False,current_user:dict=Depends(get_current_user)):
old=execute_query_single("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL",(allocation_id,))
if not old: raise HTTPException(404,"Allokeringen findes ikke")
if old.get('sag_id'): require_case(current_user,old['sag_id'])
duration=old["ends_at"]-old["starts_at"]
return create_allocation(AllocationInput(sag_id=old["sag_id"],user_id=user_id or old["user_id"],starts_at=starts_at,ends_at=starts_at+duration,note=old["note"],source="copy",confirm_conflicts=confirm_conflicts),current_user)

View File

@ -0,0 +1,534 @@
"""Personal work queues; planning never creates billable time implicitly."""
from datetime import date, datetime, time, timedelta, timezone
from contextlib import nullcontext
from decimal import Decimal
from typing import Literal
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.encoders import jsonable_encoder
from psycopg2.extras import Json
from pydantic import BaseModel, Field
from app.core.auth_dependencies import get_current_user
from app.core.database import atomic_transaction, execute_query, execute_query_single, _transaction_connection
from app.core.case_visibility import require_case,hidden_case_ids,filter_data
from .router import TZ, CLOSED_STATUSES, _aware, _can_edit, _can_view_target, _require_view
def require_schema():
row = execute_query_single("SELECT to_regclass('planner_activity_types') IS NOT NULL AS ready")
if not row or not row['ready']:
raise HTTPException(503, 'Planlægning afventer databaseopdatering. Anvend migration 1070.')
router = APIRouter(dependencies=[Depends(require_schema)])
FINISHED = CLOSED_STATUSES | {'løst','resolved','annulleret', 'cancelled', 'canceled'}
WAITING = {'afventer', 'on_hold', 'waiting', 'blocked', 'blokeret'}
def db_utc(value):
"""Existing case/time TIMESTAMP columns store UTC without an offset."""
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
def require_target(user, target, edit=False):
_require_view(user)
visible=_can_view_target(user,target)
if not visible or (edit and not _can_edit(user,target)):
raise HTTPException(403, 'Du har ikke adgang til denne medarbejders plan')
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.view', 'tickets.view'}):
raise HTTPException(403, 'Du har ikke adgang til sager')
def audit(user, action, entity, entity_id, before=None, after=None, sag_id=None):
entry = execute_query_single(
"""INSERT INTO planner_allocation_history
(sag_id,action,changed_by,before_value,after_value,entity_type,entity_id)
VALUES(%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
(sag_id, action, user['id'], Json(jsonable_encoder(before)),
Json(jsonable_encoder(after)), entity, entity_id))
if sag_id and entity=='assignee' and action in {'assign','transfer','queue_change'} and entity_id!=user['id']:
key=(f'queue:{entity_id}:{sag_id}:{int(datetime.now(timezone.utc).timestamp()//300)}'
if action=='queue_change' else f'audit:{entry["id"]}')
notify_case(user,sag_id,key,
'Din allokering eller arbejdsrækkefølge er ændret.',[entity_id])
return entry['id']
def notify_case(user,sag_id,key,message,recipients):
ids=sorted(set(int(uid) for uid in recipients if int(uid)!=int(user['id'])))
if not ids or not sag_id:
return
execute_query('''INSERT INTO sag_reminders(sag_id,title,message,event_type,trigger_type,trigger_config,
recipient_user_ids,notify_mattermost,notify_email,notify_frontend,override_user_preferences,
recurrence_type,scheduled_at,next_check_at,created_by_user_id)
VALUES(%s,'Planlægning ændret',%s,'reminder','time_based',%s,%s,FALSE,FALSE,TRUE,TRUE,'once',NOW(),NOW(),%s)
ON CONFLICT DO NOTHING''',(sag_id,message,Json({'planner_key':key}),ids,user['id']),fetch=False)
def lock_queue(target):
# The same employee lock is used for membership, state and order changes.
execute_query('SELECT pg_advisory_xact_lock(%s,%s)', (252, target))
execute_query('INSERT INTO planner_queue_versions(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (target,), fetch=False)
return execute_query_single('SELECT version FROM planner_queue_versions WHERE user_id=%s FOR UPDATE', (target,))['version']
def bump_queue(target):
execute_query('UPDATE planner_queue_versions SET version=version+1 WHERE user_id=%s', (target,), fetch=False)
def ready(row, now):
if str(row.get('status') or '').lower() in FINISHED | WAITING:
return False
if row.get('work_status') in {'waiting', 'paused', 'done'}:
return False
if row.get('snoozed_until') and _aware(row['snoozed_until']) > now:
return False
deferred=row.get('deferred_until')
if deferred:
if isinstance(deferred,datetime):
if db_utc(deferred)>now:
return False
elif deferred>now.date():
return False
if row.get('deferred_until_case_id'):
targets = {s.strip().casefold() for s in re.split(r'[,;\n]',str(row.get('deferred_until_status') or '')) if s.strip()}
if not targets or str(row.get('deferred_case_status') or '').casefold() not in targets:
return False
if row.get('start_date'):
start = datetime.combine(row['start_date'], row.get('start_time') or time.min, TZ)
if start > now:
return False
return True
def remaining(row):
if row.get('remaining_minutes') is not None:
return int(row['remaining_minutes'])
estimate = row.get('estimated_minutes')
return None if estimate is None else max(0, int(estimate) - int(row.get('actual_minutes') or 0))
def queue_rows(target, limit=100, offset=0, user=None, q=''):
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
closed = FINISHED | set(_fetch_closed_case_statuses())
rows = execute_query(
"""SELECT * FROM (SELECT a.*,s.titel,s.status,s.priority,s.deadline,s.start_date,s.start_time,s.updated_at AS case_updated_at,
s.deferred_until,s.deferred_until_case_id,s.deferred_until_status,waiting.status AS deferred_case_status,
s.type,s.estimated_minutes AS case_estimated_minutes,
ARRAY(WITH RECURSIVE labels AS (
SELECT t.id,t.name,t.parent_id FROM tags t JOIN entity_tags et ON et.tag_id=t.id
WHERE et.entity_type='case' AND et.entity_id=s.id
UNION SELECT p.id,p.name,p.parent_id FROM tags p JOIN labels l ON p.id=l.parent_id)
SELECT name::text FROM labels UNION SELECT tag_navn::text FROM sag_tags WHERE sag_id=s.id AND deleted_at IS NULL) AS tags,
c.name AS customer_name,COALESCE(t.minutes,0) AS actual_minutes,COALESCE(t.active,FALSE) AS active_timer,
COALESCE(p.minutes,0) AS future_planned_minutes,COALESCE(p.slots,'[]'::jsonb) AS planned_slots
FROM sag_case_assignees a JOIN sag_sager s ON s.id=a.sag_id
LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id
LEFT JOIN customers c ON c.id=s.customer_id
LEFT JOIN LATERAL (SELECT SUM(COALESCE(faktisk_tid_min,
ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes,
BOOL_OR(aktiv_timer AND slut_tid IS NULL) AS active
FROM tmodule_times WHERE sag_id=a.sag_id AND medarbejder_id=a.user_id
AND status<>'rejected') t ON TRUE
LEFT JOIN LATERAL (SELECT SUM(EXTRACT(EPOCH FROM
(ends_at-GREATEST(starts_at,NOW())))/60)::int AS minutes,
jsonb_agg(jsonb_build_object('event_id',event_id,'starts_at',starts_at,'ends_at',ends_at)
ORDER BY starts_at,id) AS slots
FROM planner_allocations WHERE sag_id=a.sag_id AND user_id=a.user_id
AND deleted_at IS NULL AND ends_at>NOW()) p ON TRUE
WHERE a.user_id=%s AND s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s) AND NOT(s.id=ANY(%s))
) entries WHERE (%s='' OR CONCAT_WS(' ',sag_id::text,titel,customer_name,type,ARRAY_TO_STRING(tags,' ')) ILIKE %s)
ORDER BY pinned DESC,queue_position,sag_id LIMIT %s OFFSET %s""",
(target, list(closed), list(hidden_case_ids(user)) if user else [], q, '%'+q+'%', limit, offset)) or []
for row in rows:
for key in ('deadline','deferred_until','case_updated_at'):
if isinstance(row.get(key),datetime):
row[key]=db_utc(row[key]).astimezone(TZ)
return rows
@router.get('/queue')
def get_queue(user_id: int | None = None, limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0), user: dict = Depends(get_current_user), q: str = ''):
target = user_id or int(user['id'])
require_target(user, target)
# A single repeatable snapshot prevents a stale body being paired with a new version.
with atomic_transaction():
execute_query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', fetch=False)
version = execute_query_single('SELECT version FROM planner_queue_versions WHERE user_id=%s', (target,))
rows = queue_rows(target, limit, offset,user,q.strip()[:200])
now = datetime.now(TZ)
for row in rows:
row['ready'] = ready(row, now)
row['remaining_work_minutes'] = remaining(row)
row['queue_minutes'] = (None if remaining(row) is None else
max(0, remaining(row) - row['future_planned_minutes']))
row['url'] = f"/sag/{row['sag_id']}"
row['planning_status']='fast_planlagt' if row['future_planned_minutes'] else 'i_arbejdsko'
row['has_queue_work']=row['queue_minutes'] is None or row['queue_minutes']>0
return {'user_id': target, 'version': version['version'] if version else 1,
'items': rows, 'limit': limit, 'offset': offset}
class QueueChange(BaseModel):
version: int = Field(ge=1)
before_sag_id: int | None = None
pinned: bool | None = None
work_status: Literal['ready', 'working', 'paused', 'waiting', 'done'] | None = None
estimated_minutes: int | None = Field(default=None, ge=0, le=525600)
remaining_minutes: int | None = Field(default=None, ge=0, le=525600)
snoozed_until: datetime | None = None
@router.patch('/queue/{target}/{sag_id}')
def change_queue(target: int, sag_id: int, payload: QueueChange, user: dict = Depends(get_current_user)):
require_target(user, target, True)
require_case(user,sag_id)
with (nullcontext() if _transaction_connection.get() else atomic_transaction()):
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
version = lock_queue(target)
if version != payload.version:
raise HTTPException(409, 'Køen er ændret. Genindlæs og prøv igen.')
old = execute_query_single('SELECT * FROM sag_case_assignees WHERE user_id=%s AND sag_id=%s FOR UPDATE', (target, sag_id))
if not old:
raise HTTPException(404, 'Allokering findes ikke')
values = {k: v for k, v in payload.model_dump().items() if k in payload.model_fields_set and k not in {'version', 'before_sag_id'}}
if values.get('work_status') is None and 'work_status' in values:
raise HTTPException(400, 'Arbejdsstatus skal angives')
if values.get('pinned') is None and 'pinned' in values:
raise HTTPException(400, 'Fastlåsning skal angives')
if values.get('snoozed_until') is not None:
values['snoozed_until'] = _aware(values['snoozed_until'])
if 'before_sag_id' in payload.model_fields_set or 'pinned' in values:
group = values.get('pinned', old['pinned'])
siblings = execute_query('SELECT sag_id,queue_position FROM sag_case_assignees WHERE user_id=%s AND pinned=%s AND sag_id<>%s ORDER BY queue_position,sag_id', (target, group, sag_id)) or []
index = len(siblings)
if payload.before_sag_id is not None:
index = next((i for i, r in enumerate(siblings) if r['sag_id'] == payload.before_sag_id), -1)
if index < 0:
raise HTTPException(409, 'Målopgaven findes ikke i samme køgruppe')
low = Decimal(siblings[index-1]['queue_position']) if index else Decimal(0)
high = Decimal(siblings[index]['queue_position']) if index < len(siblings) else low + 2048
if high-low < Decimal('0.000001'):
for i, row in enumerate(siblings):
execute_query('UPDATE sag_case_assignees SET queue_position=%s WHERE user_id=%s AND sag_id=%s', ((i+1)*1024, target, row['sag_id']), fetch=False)
low = Decimal(index*1024)
high = low+1024
values['queue_position'] = (low+high)/2
if values:
assignments = ','.join(f'{key}=%s' for key in values)
execute_query(f'UPDATE sag_case_assignees SET {assignments},version=version+1 WHERE user_id=%s AND sag_id=%s', (*values.values(), target, sag_id), fetch=False)
bump_queue(target)
new = execute_query_single('SELECT * FROM sag_case_assignees WHERE user_id=%s AND sag_id=%s', (target, sag_id))
audit(user, 'queue_change', 'assignee', target, old, new, sag_id)
return {'version': version+1, 'item': new}
@router.get('/next-task')
def next_task(user: dict = Depends(get_current_user)):
require_target(user, int(user['id']))
now = datetime.now(TZ)
# Scan all pages so a long waiting queue cannot hide a ready task.
candidates = []
offset = 0
while True:
rows = queue_rows(int(user['id']), 500, offset,user)
candidates.extend(r for r in rows if ready(r, now))
if len(rows) < 500 or candidates:
break
offset += 500
task = candidates[0] if candidates else None
appointment = execute_query_single("""SELECT id,sag_id,starts_at,ends_at FROM planner_allocations
WHERE user_id=%s AND deleted_at IS NULL AND ends_at>%s ORDER BY starts_at LIMIT 1""", (user['id'], now))
settings = execute_query_single('SELECT appointment_warning_minutes FROM planner_settings WHERE id=1') or {}
warning = False
shorter = None
if appointment and task:
gap = max(0, int((_aware(appointment['starts_at'])-now).total_seconds()/60))
estimate = remaining(task)
warning = gap <= settings.get('appointment_warning_minutes', 30) and (estimate is None or estimate > gap)
shorter = next((r for r in candidates if remaining(r) is not None and remaining(r) <= gap), None)
def result(row):
return None if row is None else {'sag_id': row['sag_id'], 'title': row['titel'],
'remaining_minutes': remaining(row), 'url': f"/sag/{row['sag_id']}"}
return filter_data({'task': result(task), 'appointment': appointment, 'warning': warning,
'shorter_task': result(shorter), 'message': None if task else 'Du har ingen opgaver klar.'},hidden_case_ids(user))
class TransferBookingVersion(BaseModel):
id: int
version: int
event_version: int
class TransferInput(BaseModel):
source_user_id: int = Field(gt=0)
target_user_id: int = Field(gt=0)
source_version: int = Field(ge=1)
target_version: int = Field(ge=1)
future_bookings_action: Literal['transfer', 'remove'] | None = None
future_bookings: list[TransferBookingVersion] = Field(default_factory=list)
confirm_conflicts: bool = False
@router.post('/cases/{sag_id}/transfer')
def transfer(sag_id:int,payload:TransferInput,user:dict=Depends(get_current_user)):
require_case(user,sag_id)
source,target = payload.source_user_id,payload.target_user_id
if source==target:
raise HTTPException(400,'Vælg en anden medarbejder')
for uid in (source,target):
require_target(user,uid,True)
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
raise HTTPException(403,'Du må ikke omfordele medarbejdere')
from .calendar import employee_locks, check_event_conflicts
with atomic_transaction():
# Lock shared parents before employee/queue locks, as the event editor does.
execute_query('''SELECT e.id FROM planner_events e WHERE e.id IN
(SELECT event_id FROM planner_allocations WHERE sag_id=%s AND user_id=%s
AND deleted_at IS NULL AND ends_at>NOW()) ORDER BY e.id FOR UPDATE''',(sag_id,source))
employee_locks([source,target])
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
versions = {uid:lock_queue(uid) for uid in sorted([source,target])}
if versions[source]!=payload.source_version or versions[target]!=payload.target_version:
raise HTTPException(409,'En af køerne er ændret. Genindlæs.')
old = execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s FOR UPDATE',(sag_id,source))
if not old:
raise HTTPException(404,'Allokering findes ikke')
if execute_query_single('SELECT 1 FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target)):
raise HTTPException(409,'Medarbejderen er allerede allokeret. Justér fordelingen i stedet.')
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active',(target,)):
raise HTTPException(400,'Vælg en aktiv medarbejder')
bookings=execute_query('''SELECT a.*,e.version AS event_version FROM planner_allocations a
JOIN planner_events e ON e.id=a.event_id WHERE a.sag_id=%s AND a.user_id=%s
AND a.deleted_at IS NULL AND a.ends_at>NOW() ORDER BY a.id FOR UPDATE OF a''',(sag_id,source)) or []
snapshot=[{k:r[k] for k in ('id','version','event_version')} for r in bookings]
if bookings and payload.future_bookings_action is None:
raise HTTPException(409,{'code':'future_bookings_choice',
'message':'Hvad skal der ske med de kommende reservationer?',
'booking_count':len(bookings),'bookings':snapshot})
if payload.future_bookings_action and [r.model_dump() for r in payload.future_bookings]!=snapshot:
raise HTTPException(409,'Reservationerne er ændret. Genindlæs og vælg igen.')
if any(r['locked'] for r in bookings):
raise HTTPException(409,'Fjern tidslås før reservationerne ændres')
if payload.future_bookings_action=='transfer':
conflicts=[]
for booking in bookings:
conflicts.extend(check_event_conflicts([target],booking['starts_at'],booking['ends_at'],booking['event_id']))
if conflicts and not payload.confirm_conflicts:
raise HTTPException(409,{'message':'De overdragne tider konflikter med medarbejderens kalender eller arbejdstid',
'conflicts':conflicts,'confirm_conflicts':True})
position = execute_query_single('SELECT COALESCE(MAX(queue_position),0)+1024 AS position FROM sag_case_assignees WHERE user_id=%s',(target,))['position']
# Preserve already performed work on the source employee; transfer the remainder.
totals = execute_query_single('''SELECT SUM(COALESCE(faktisk_tid_min,ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes
FROM tmodule_times WHERE sag_id=%s AND medarbejder_id=%s AND status<>'rejected' ''',(sag_id,source)) or {}
rest = remaining(dict(old,actual_minutes=totals.get('minutes') or 0))
new = execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,
estimated_minutes,remaining_minutes,queue_position,work_status)
VALUES(%s,%s,%s,%s,%s,%s,'ready') RETURNING *''',(sag_id,target,user['id'],rest,rest,position))
for booking in bookings:
if payload.future_bookings_action=='transfer':
changed=execute_query_single('''UPDATE planner_allocations SET user_id=%s,version=version+1,
updated_at=NOW(),updated_by=%s WHERE id=%s RETURNING *''',(target,user['id'],booking['id']))
else:
changed=execute_query_single('''UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,
updated_at=NOW(),updated_by=%s WHERE id=%s RETURNING *''',(user['id'],booking['id']))
audit(user,'transfer' if payload.future_bookings_action=='transfer' else 'cancel',
'allocation',booking['id'],booking,changed,sag_id)
for event_id in sorted({r['event_id'] for r in bookings}):
# Cancellation of the last participant already versions/deletes the parent via its trigger.
execute_query('''UPDATE planner_events SET version=version+1,updated_at=NOW(),updated_by=%s
WHERE id=%s AND deleted_at IS NULL''',(user['id'],event_id),fetch=False)
execute_query('DELETE FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,source),fetch=False)
audit(user,'transfer','assignee',target,old,new,sag_id)
return {'item':new,'responsible_unchanged':True}
class RemoveInput(BaseModel):
version:int=Field(ge=1)
@router.delete('/cases/{sag_id}/assignees/{target}')
def remove_assignee(sag_id:int,target:int,version:int,user:dict=Depends(get_current_user)):
require_case(user,sag_id)
require_target(user,target,True)
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
raise HTTPException(403,'Du må ikke fjerne allokeringer')
with atomic_transaction():
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
if lock_queue(target)!=version:
raise HTTPException(409,'Køen er ændret')
old=execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target))
if not old:
raise HTTPException(404,'Allokering findes ikke')
if execute_query_single('SELECT 1 FROM planner_allocations WHERE sag_id=%s AND user_id=%s AND deleted_at IS NULL AND ends_at>NOW()',(sag_id,target)):
raise HTTPException(409,'Fjern eller flyt fremtidige aftaler først')
execute_query('DELETE FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(sag_id,target),fetch=False)
audit(user,'unassign','assignee',target,old,None,sag_id)
return {'removed':True}
class AssignmentInput(BaseModel):
user_id: int = Field(gt=0)
estimated_minutes:int|None=Field(default=None,ge=0,le=525600)
@router.post('/cases/{sag_id}/assignees', status_code=201)
def add_assignee(sag_id: int, payload: AssignmentInput, user: dict = Depends(get_current_user)):
require_case(user,sag_id)
require_target(user, payload.user_id, True)
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin', 'planner.allocate'}):
raise HTTPException(403, 'Du må ikke allokere medarbejdere')
with atomic_transaction():
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR KEY SHARE',(sag_id,))
lock_queue(payload.user_id)
if not execute_query_single('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (sag_id,)):
raise HTTPException(404, 'Sagen findes ikke')
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active', (payload.user_id,)):
raise HTTPException(400, 'Vælg en aktiv medarbejder')
row = execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,estimated_minutes,queue_position)
SELECT %s,%s,%s,%s,COALESCE(MAX(queue_position),0)+1024 FROM sag_case_assignees WHERE user_id=%s
ON CONFLICT DO NOTHING RETURNING *''', (sag_id,payload.user_id,user['id'],payload.estimated_minutes,payload.user_id))
if row:
audit(user,'assign','assignee',payload.user_id,None,row,sag_id)
return {'created': bool(row)}
class WorkAction(BaseModel):
version:int=Field(ge=1)
timer_action:bool=False
async def work_action(sag_id,payload,user,resume):
from app.timetracking.backend.router import pause_live_timer_v1,resume_live_timer_v1
uid=int(user['id'])
require_target(user,uid,True)
require_case(user,sag_id)
with atomic_transaction():
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR NO KEY UPDATE',(uid,))
result=change_queue(uid,sag_id,QueueChange(version=payload.version,work_status='working' if resume else 'paused'),user)
if payload.timer_action:
timer=execute_query_single('''SELECT id,aktiv_timer FROM tmodule_times WHERE medarbejder_id=%s AND sag_id=%s
AND slut_tid IS NULL AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id DESC LIMIT 1''',(uid,sag_id))
if timer and (bool(timer['aktiv_timer']) != resume):
# This transaction already serializes against the existing timer row lock.
resume_fn=getattr(resume_live_timer_v1,'__wrapped__',resume_live_timer_v1)
pause_fn=getattr(pause_live_timer_v1,'__wrapped__',pause_live_timer_v1)
result['timer']=await resume_fn({'time_id':timer['id']},user) if resume else await pause_fn(user)
return result
@router.post('/queue/{sag_id}/pause')
async def pause_work(sag_id:int,payload:WorkAction,user:dict=Depends(get_current_user)):
return await work_action(sag_id,payload,user,False)
@router.post('/queue/{sag_id}/resume')
async def resume_work(sag_id:int,payload:WorkAction,user:dict=Depends(get_current_user)):
return await work_action(sag_id,payload,user,True)
class AssigneeEstimateInput(BaseModel):
user_id:int=Field(gt=0)
queue_version:int=Field(ge=1)
estimated_minutes:int|None=Field(default=None,ge=0,le=525600)
remaining_minutes:int|None=Field(default=None,ge=0,le=525600)
class CasePlanningInput(BaseModel):
updated_at:datetime
start_date:date|None=None
start_time:time|None=None
deadline:datetime|None=None
deadline_date:date|None=None
estimated_minutes:int|None=Field(default=None,ge=0,le=525600)
assignee_estimates:list[AssigneeEstimateInput]=Field(default_factory=list,max_length=100)
add_assignee:AssignmentInput|None=None
@router.get('/cases/{sag_id}/planning')
def case_planning(sag_id:int,user:dict=Depends(get_current_user)):
require_target(user,int(user['id']))
require_case(user,sag_id)
row=execute_query_single('SELECT id,titel,start_date,start_time,deadline,estimated_minutes,updated_at FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,))
if not row:
raise HTTPException(404,'Sagen findes ikke')
row['assignees']=execute_query('''SELECT a.*,COALESCE(u.full_name,u.username) AS name,COALESCE(v.version,1) AS queue_version
FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id
LEFT JOIN planner_queue_versions v ON v.user_id=a.user_id WHERE a.sag_id=%s ORDER BY name''',(sag_id,)) or []
row['updated_at']=db_utc(row['updated_at'])
if row['deadline']:
row['deadline']=db_utc(row['deadline']).astimezone(TZ)
return row
@router.patch('/cases/{sag_id}/planning')
def update_case_planning(sag_id:int,payload:CasePlanningInput,user:dict=Depends(get_current_user)):
require_case(user,sag_id)
metadata_fields={'start_date','start_time','deadline','deadline_date','estimated_minutes'}
if {'deadline','deadline_date'} <= payload.model_fields_set:
raise HTTPException(400,'Angiv enten deadline eller deadlinedato')
if payload.model_fields_set & metadata_fields and not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.edit','tickets.edit'}):
raise HTTPException(403,'Du må ikke ændre sagsplanlægningen')
values={k:v for k,v in payload.model_dump().items() if k in payload.model_fields_set and k not in {'updated_at','assignee_estimates','add_assignee','deadline_date'}}
estimates=payload.assignee_estimates
if len({r.user_id for r in estimates})!=len(estimates):
raise HTTPException(400,'En medarbejder må kun angives én gang')
for row in estimates:
require_target(user,row.user_id,True)
if payload.add_assignee:
require_target(user,payload.add_assignee.user_id,True)
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
raise HTTPException(403,'Du må ikke allokere medarbejdere')
if values.get('deadline') is not None:
values['deadline']=db_utc(values['deadline']).replace(tzinfo=None)
with atomic_transaction():
old=execute_query_single('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,))
if not old:
raise HTTPException(404,'Sagen findes ikke')
if db_utc(old['updated_at'])!=db_utc(payload.updated_at):
raise HTTPException(409,'Sagen er ændret. Genindlæs.')
if 'deadline_date' in payload.model_fields_set:
clock=db_utc(old['deadline']).astimezone(TZ).time() if old['deadline'] else time(17)
values['deadline']=(datetime.combine(payload.deadline_date,clock,TZ).astimezone(timezone.utc).replace(tzinfo=None)
if payload.deadline_date else None)
ids={r.user_id for r in estimates}
if payload.add_assignee:
ids.add(payload.add_assignee.user_id)
versions={uid:lock_queue(uid) for uid in sorted(ids)}
for row in estimates:
before=execute_query_single('SELECT * FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s FOR UPDATE',(sag_id,row.user_id))
if not before:
raise HTTPException(409,'En medarbejders allokering er ændret')
fields={k:v for k,v in row.model_dump().items() if k in row.model_fields_set and k in {'estimated_minutes','remaining_minutes'} and before.get(k)!=v}
if fields:
if versions[row.user_id]!=row.queue_version:
raise HTTPException(409,'En medarbejders kø er ændret. Genindlæs.')
setters=','.join(f'{key}=%s' for key in fields)
execute_query(f'UPDATE sag_case_assignees SET {setters},version=version+1 WHERE sag_id=%s AND user_id=%s',(*fields.values(),sag_id,row.user_id),fetch=False)
bump_queue(row.user_id)
audit(user,'queue_change','assignee',row.user_id,before,{**before,**fields},sag_id)
if payload.add_assignee:
new_assignee=payload.add_assignee
if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s AND is_active',(new_assignee.user_id,)):
raise HTTPException(400,'Vælg en aktiv medarbejder')
added=execute_query_single('''INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id,estimated_minutes,queue_position)
SELECT %s,%s,%s,%s,COALESCE(MAX(queue_position),0)+1024 FROM sag_case_assignees WHERE user_id=%s
ON CONFLICT DO NOTHING RETURNING *''',(sag_id,new_assignee.user_id,user['id'],new_assignee.estimated_minutes,new_assignee.user_id))
if not added:
raise HTTPException(409,'Medarbejderen er allerede allokeret. Genindlæs.')
audit(user,'assign','assignee',new_assignee.user_id,None,added,sag_id)
if values:
setters=','.join(f'{key}=%s' for key in values)
execute_query(f'UPDATE sag_sager SET {setters},updated_at=NOW() WHERE id=%s',(*values.values(),sag_id),fetch=False)
elif estimates or payload.add_assignee:
execute_query('UPDATE sag_sager SET updated_at=NOW() WHERE id=%s',(sag_id,),fetch=False)
new=execute_query_single('SELECT start_date,start_time,deadline,estimated_minutes,updated_at FROM sag_sager WHERE id=%s',(sag_id,))
if new['start_time'] and not new['start_date']:
raise HTTPException(400,'Startklokkeslæt kræver en startdato')
audit(user,'case_plan_update','case',sag_id,old,new,sag_id)
new['updated_at']=db_utc(new['updated_at'])
if new['deadline']:
new['deadline']=db_utc(new['deadline']).astimezone(TZ)
return new

View File

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
import os
from fastapi.templating import Jinja2Templates
from app.core.auth_dependencies import get_current_user
@ -9,3 +10,18 @@ templates = Jinja2Templates(directory="app")
@router.get("/planner", response_class=HTMLResponse)
async def planner(request: Request, current_user: dict = Depends(get_current_user)):
return templates.TemplateResponse("modules/planner/templates/index.html", {"request": request, "current_user": current_user})
@router.get("/planning", response_class=HTMLResponse)
async def planning(request: Request, current_user: dict = Depends(get_current_user)):
if os.environ.get('PLANNING_UI_ENABLED','true').lower() in {'0','false','no'}:
return RedirectResponse('/planner',status_code=307)
return templates.TemplateResponse("modules/planner/templates/work.html", {"request": request, "current_user": current_user})
@router.get('/planning/absence', response_class=HTMLResponse)
async def absence_status(request: Request, current_user: dict = Depends(get_current_user)):
from app.modules.planner.backend.calendar import require_absence_report
require_absence_report(current_user)
return templates.TemplateResponse('modules/planner/templates/absence.html',
{'request':request,'current_user':current_user})

View File

@ -0,0 +1,15 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Fravær · status og rapport · BMC Hub{% endblock %}
{% block extra_css %}<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css"><link rel="stylesheet" href="/static/css/planning-dates.css?v=20261018-1">{% endblock %}
{% block content %}
<main id="absenceReport">
<header class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"><div><h1 class="h3 mb-1">Fravær · status og rapport</h1><p class="text-secondary mb-0">Ferie, sygdom og andet fravær for de medarbejdere, du har adgang til.</p></div><a href="/planning" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i>Planlægning</a></header>
<section class="card mb-3"><div class="card-body"><form id="absenceFilters" class="row g-3 align-items-end"><div class="col-sm-3"><label class="form-label" for="absenceFrom">Fra og med</label><input id="absenceFrom" name="start_date" type="date" class="form-control" required></div><div class="col-sm-3"><label class="form-label" for="absenceTo">Til og med</label><input id="absenceTo" name="end_date" type="date" class="form-control" required></div><div class="col-sm-3"><label class="form-label" for="absenceEmployee">Medarbejder</label><select id="absenceEmployee" name="user_id" class="form-select"><option value="">Alle tilladte medarbejdere</option></select></div><div class="col-sm-3 d-flex gap-2"><button class="btn btn-primary" type="submit">Vis rapport</button><button id="absenceExport" class="btn btn-outline-secondary" type="button" disabled><i class="bi bi-download me-1" aria-hidden="true"></i>CSV</button></div></form></div></section>
<p id="absenceError" class="alert alert-danger" role="alert" hidden></p><p id="absenceStatus" class="small text-secondary" role="status" aria-live="polite"></p>
<div id="absenceTotals" class="row g-3 mb-3"></div>
<section class="card mb-3"><div class="card-header"><h2 class="h5 mb-0">Overblik pr. fraværstype</h2></div><div class="table-responsive"><table class="table table-hover mb-0"><thead><tr><th>Fraværstype / medarbejder</th><th>Arbejdsdage</th><th>Planlagte timer</th><th>Faktisk registreret</th></tr></thead><tbody id="absenceSummary"></tbody></table></div></section>
<section class="card"><div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"><h2 class="h5 mb-0">Fraværsperioder pr. type</h2><details><summary class="small">Hvordan læses rapporten?</summary><p class="small mt-2 mb-0">Perioden er inklusive begge datoer. Planlagte dage og timer beregnes i medarbejderens arbejdstid; overlap tælles kun én gang i overblikket. Inden for hver type tælles overlap kun én gang. Overlap mellem forskellige typer kan betyde, at typernes sum er større end det samlede overblik. En dag svarer til medarbejderens normale arbejdsdag. Registreret fravær vises separat og kræver en eksplicit tidsregistrering. Årsager vises kun med særskilt adgang. Private noter eksporteres aldrig.</p></details></div><div class="table-responsive"><table class="table table-hover mb-0"><thead><tr><th>Medarbejder</th><th>Fravær</th><th>Fra</th><th>Til</th><th>Arbejdsdage</th><th>Planlagte timer</th><th>Faktisk registreret</th><th>Status</th></tr></thead><tbody id="absenceRows"></tbody></table></div><nav class="card-body d-flex justify-content-between align-items-center" aria-label="Fraværsperioder"><button id="absencePrev" class="btn btn-sm btn-outline-secondary" disabled>Forrige</button><span id="absencePage" class="small text-secondary"></span><button id="absenceNext" class="btn btn-sm btn-outline-secondary" disabled>Næste</button></nav></section>
<p class="small text-secondary mt-2">Manglende arbejdstider vises som ukendt. Rapporten er et overblik over planlagt og registreret fravær; den beregner ikke resterende feriesaldo.</p>
</main>
{% endblock %}
{% block extra_js %}<script src="/static/vendor/flatpickr/flatpickr.min.js"></script><script src="/static/vendor/flatpickr/da.js"></script><script src="/static/js/planning-dates.js?v=20261018-1"></script><script src="/static/js/absence-report.js?v=20261018-4"></script>{% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,122 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Planlægning · BMC Hub{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css"><link rel="stylesheet" href="/static/css/planning-dates.css?v=20261018-1">
<style>
.work-page{width:100%;color:var(--text-primary)}
.work-tabs{margin:1rem 0;flex-wrap:wrap;gap:.25rem;border-color:var(--frame-border)}
.work-tabs .nav-link{color:var(--text-secondary)}
.work-tabs .nav-link.active{color:var(--accent);background:var(--bg-card);border-color:var(--frame-border) var(--frame-border) var(--bg-card);font-weight:600}
.work-grid{display:grid;grid-template-columns:minmax(280px,1fr) minmax(360px,2fr);gap:1rem}
.work-card{padding:1rem;margin-bottom:.75rem}
.work-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;flex-wrap:wrap}
.work-actions{display:flex;gap:.35rem;flex-wrap:wrap}
.work-muted{color:var(--text-secondary);font-size:.85rem}
.work-page dialog{width:min(540px,95vw);max-height:90vh;overflow:auto;border:2px solid var(--frame-border-strong);border-top:4px solid var(--accent);border-radius:var(--border-radius);box-shadow:var(--frame-shadow);background:var(--bg-card);color:var(--text-primary);padding:1.25rem}
.work-page dialog::backdrop{background:rgba(0,0,0,.5)}
.work-page label{display:block;margin:.5rem 0}
.work-page input,.work-page select,.work-page textarea{max-width:100%}
.work-page .btn-outline-primary{--bs-btn-color:var(--accent);--bs-btn-border-color:var(--accent);--bs-btn-hover-bg:var(--accent);--bs-btn-hover-border-color:var(--accent);--bs-btn-active-bg:var(--accent);--bs-btn-active-border-color:var(--accent)}
#workCalendar{min-height:550px;padding:1rem;--fc-border-color:var(--frame-border);--fc-page-bg-color:var(--bg-card);--fc-neutral-bg-color:var(--accent-light);--fc-button-bg-color:var(--accent);--fc-button-border-color:var(--accent);--fc-button-hover-bg-color:color-mix(in srgb,var(--accent) 85%,black);--fc-button-hover-border-color:var(--accent);--fc-button-active-bg-color:color-mix(in srgb,var(--accent) 75%,black);--fc-button-active-border-color:var(--accent)}
.work-help{position:relative;display:inline-block;vertical-align:middle;color:var(--text-secondary)}
.work-help summary{display:inline-flex;align-items:center;justify-content:center;cursor:pointer;list-style:none;min-width:28px;min-height:28px;border-radius:50%}
.work-help summary::-webkit-details-marker{display:none}
.work-help summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.work-help[open] summary{color:var(--accent);background:var(--accent-light)}
.work-help>div{position:absolute;z-index:10;right:0;top:100%;width:min(320px,75vw);padding:.85rem;font-size:.85rem;font-weight:400;line-height:1.5;color:var(--text-primary);background:var(--bg-card);border:1px solid var(--frame-border-strong);border-radius:var(--border-radius);box-shadow:var(--frame-shadow)}
.work-page dialog .work-help>div{position:static;width:auto;margin-top:.5rem}
.unassigned-table td{vertical-align:middle;padding:.85rem .65rem}
.unassigned-table .case-name{font-weight:600;text-decoration:none}
.unassigned-table .case-number{color:var(--text-secondary);font-weight:400;margin-right:.4rem}
.unassigned-empty{text-align:center;padding:2rem 1rem;color:var(--text-secondary)}
.unassigned-empty .bi{font-size:2rem;color:var(--accent)}
@media(max-width:800px){.unassigned-table thead{display:none}.unassigned-table,.unassigned-table tbody,.unassigned-table tr,.unassigned-table td{display:block;width:100%}.unassigned-table tr{border-bottom:1px solid var(--frame-border);padding:.6rem 0}.unassigned-table td{border:0;padding:.35rem .25rem}.unassigned-table td[data-label]::before{content:attr(data-label) ": ";color:var(--text-secondary);font-size:.85rem}.unassigned-table td:last-child .btn{width:100%}}
.work-task-values{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.5rem;margin-top:.85rem!important}
.work-value-button{--value-accent:var(--accent);display:flex;flex-direction:column;justify-content:space-between;gap:.5rem;min-height:76px;min-width:0;text-align:left;padding:.65rem .7rem;border:1px solid var(--frame-border);border-radius:10px;background:var(--bg-card);color:var(--text-primary);cursor:grab;-webkit-user-drag:element;box-shadow:0 1px 2px rgba(15,47,72,.03);transition:border-color .15s,box-shadow .15s,background .15s}
.work-value-button[data-task-kind=deadline]{--value-accent:#a16b16}
.work-value-button[data-task-kind=slot]{--value-accent:#336699}
.work-value-button:hover{border-color:color-mix(in srgb,var(--value-accent) 55%,var(--frame-border));background:color-mix(in srgb,var(--value-accent) 3%,var(--bg-card));box-shadow:0 3px 9px rgba(15,47,72,.08)}
.work-value-button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.work-value-button:active{cursor:grabbing}
.work-value-button:disabled{opacity:.55;cursor:default;-webkit-user-drag:none;box-shadow:none}
.work-value-label{display:flex;align-items:center;gap:.4rem;font-size:.76rem;font-weight:500;color:var(--text-secondary);width:100%}
.work-value-icon{display:inline-flex;align-items:center;justify-content:center;flex:0 0 24px;height:24px;border-radius:7px;background:color-mix(in srgb,var(--value-accent) 9%,var(--bg-card));color:var(--value-accent);font-size:.85rem}
.work-value-grip{margin-left:auto;font-size:.7rem;opacity:.35}
.work-value-button:hover .work-value-grip{opacity:.85}
.work-value-content{font-size:.82rem;font-weight:600;line-height:1.45;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
.work-slot-values{font-size:.78rem;line-height:1.5;padding-top:.3rem}
.work-slot-values>span{font-size:.72rem}
.work-slot-values ul{display:flex;flex-wrap:wrap;gap:.35rem}
.work-slot-values li{padding:.3rem .55rem;border:1px solid var(--frame-border);border-radius:7px;background:color-mix(in srgb,var(--accent) 3%,var(--bg-card));font-variant-numeric:tabular-nums}
.work-slot-values li .bi{color:var(--accent)}
.work-page .work-card:hover{transform:none}
@media(max-width:420px){.work-task-values{gap:.35rem}.work-value-button{padding:.55rem .4rem}.work-value-label{gap:.25rem;font-size:.72rem}.work-value-icon{flex-basis:20px;height:20px}.work-value-grip{display:none}.work-value-content{font-size:.76rem}}
#workCalendar .fc-bg-event.work-reservation{--reservation-ink:var(--accent);opacity:1;background-color:color-mix(in srgb,var(--reservation-ink) 12%,transparent)!important;border-left:2px solid color-mix(in srgb,var(--reservation-ink) 38%,transparent)}
#workCalendar .fc-bg-event.work-reservation-conflict{--reservation-ink:#b42318}
#workCalendar .fc-bg-event.work-reservation .fc-event-title{display:none}
.work-reservation-label{font-style:normal;position:absolute;top:3px;left:5px;max-width:calc(100% - 10px);padding:1px 3px;border:0;border-radius:3px;background:transparent;color:var(--reservation-ink,var(--accent));font-size:.7rem;font-weight:500;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;pointer-events:auto}
button.work-reservation-label{cursor:pointer}
button.work-reservation-label:hover,button.work-reservation-label:focus-visible{background:var(--bg-card);outline:1px solid var(--frame-border)}
#workCalendar .fc-event.work-reservation:not(.fc-bg-event){background-color:color-mix(in srgb,var(--accent) 12%,var(--bg-card))!important;border-color:var(--frame-border)!important;color:var(--text-primary)}
#workCalendar .fc-event.work-reservation:not(.fc-bg-event) .fc-event-main{color:var(--text-primary)}
.work-calendar-legend{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;font-size:.78rem;color:var(--text-secondary);margin-bottom:.5rem}
.work-calendar-legend span{display:inline-flex;align-items:center;gap:.35rem}
.work-calendar-legend i{display:inline-block;width:12px;height:12px;border-radius:3px}
.work-legacy-deadline{display:none!important}
.work-employee-drops{display:flex;flex-wrap:wrap;gap:.5rem}
.work-employee-drop{border:1px dashed var(--frame-border-strong);border-radius:var(--border-radius);padding:.65rem .85rem;background:var(--bg-card);color:var(--text-primary);min-height:44px}
.work-employee-drop.is-drag-over,.work-page [data-date].is-drag-over{outline:3px solid var(--accent);background:var(--accent-light)!important}
.work-drag-hint{color:var(--text-secondary);font-size:.8rem;cursor:grab}
.work-page [data-task-id][draggable=true]{cursor:grab}
.work-page .is-dragging{opacity:.5}
.work-task-mode #workSourcePane{max-height:78vh;overflow:auto;padding:2px}
.work-task-mode .unassigned-table,.work-task-mode .unassigned-table tbody,.work-task-mode .unassigned-table tr,.work-task-mode .unassigned-table td{display:block;width:100%}
.work-task-mode .unassigned-table thead{display:none}.work-task-mode .unassigned-table tr{border-bottom:1px solid var(--frame-border);padding:.6rem 0}.work-task-mode .unassigned-table td{border:0;padding:.3rem .1rem}
.work-task-mode .unassigned-table td[data-label]::before{content:attr(data-label) ": ";color:var(--text-secondary)}
.work-task-mode #workSourcePane .work-help>div{position:static;width:auto}
.work-task-mode #workUnassigned .card-header p,.work-task-mode #workUnassigned .work-row.gap-3>p{display:none}
.work-task-mode .unassigned-table .text-md-end{text-align:left!important}
@media(max-width:800px){.work-task-mode #workSourcePane{max-height:none}.work-employee-drop{flex:1 1 130px}}
#workTeam{width:100%;height:80vh;border:0}
.work-row[draggable=true]{cursor:grab}
.work-row.is-drag-over{outline:2px solid var(--accent)}
@media(max-width:800px){.work-grid{grid-template-columns:1fr}.fc .fc-toolbar{display:flex;flex-wrap:wrap;gap:.4rem}.fc .fc-toolbar-title{font-size:1.1rem}#workCalendar{padding:.75rem}}
</style>
{% endblock %}
{% block content %}
<main class="work-page" data-user-id="{{ current_user.id }}">
<div class="work-row"><h1 class="h3 mb-0"><i class="bi bi-calendar2-week me-2" aria-hidden="true"></i>Planlægning</h1><div class="work-actions"><button class="btn btn-primary" data-work-next>Næste opgave</button><button class="btn btn-outline-primary" id="workCreate" disabled>Ny aftale</button><button class="btn btn-outline-secondary" id="workAbsence" disabled>Fravær</button><a href="/planning/absence" id="workAbsenceReport" class="btn btn-outline-secondary" hidden>Fraværsstatus og rapport</a></div></div>
<div id="workEmployeeToolbar" class="work-row my-2"><label>Medarbejder <select id="workEmployee" class="form-select form-select-sm"></select></label><button class="btn btn-sm btn-outline-secondary" id="workSettings" hidden>Indstillinger</button></div>
<nav class="work-tabs nav nav-tabs" aria-label="Planlægningsvisninger">
{% for key,label in [('work','Mit arbejde'),('tasks','Opgaver'),('team','Team og kapacitet')] %}
<button class="nav-link" data-work-tab="{{ key }}">{{ label }}</button>
{% endfor %}
</nav>
<p id="workError" class="alert alert-danger" role="alert" hidden></p>
<section id="workTaskBar" class="card p-3 mb-3" hidden>
<div class="work-row"><div><h2 class="h5 mb-1">Fordel opgaver og sæt datoer</h2><p class="work-muted mb-2">Træk Start, Deadline eller Allokeret fra opgavekortet til kalenderen. Allokeret tilføjer en ny tid hver gang.</p></div><details class="work-help"><summary aria-label="Hjælp til at trække opgaver"><i class="bi bi-info-circle" aria-hidden="true"></i></summary><div>Uden medarbejder: Træk til et navn for at tildele. Fra en medarbejders kø: Træk til et andet navn for at overdrage arbejdet. Træk inden for køen for at ændre rækkefølgen. Træk Start for at sætte sagsstart, Deadline for at sætte fristen og Allokeret for at tilføje et nyt tidsrum. Tidligere tidsrum bevares. Du kan også bruge knapperne på opgaven.</div></details></div>
<div id="workEmployeeDrops" class="work-employee-drops" aria-label="Træk opgaver til en medarbejder"></div><p id="workTaskStatus" class="small mt-2 mb-0" role="status" aria-live="polite"></p>
</section>
<section id="workDaily" class="work-grid"><div id="workSourcePane"><div id="workSourceFilter" class="work-actions mb-2" hidden><button type="button" class="btn btn-sm btn-outline-primary" data-task-source="inbox">Uden medarbejder</button><button type="button" class="btn btn-sm btn-outline-primary" data-task-source="queue">Medarbejderens kø</button></div><div id="workQueuePanel" class="card p-3 align-self-start"><div class="work-row"><h2 id="workQueueHeading" class="h5">Min arbejdskø</h2><button class="btn btn-sm btn-outline-secondary" id="workRefresh">Opdatér</button></div><label>Filter <select id="workFilter" class="form-select form-select-sm"><option value="">Alle</option><option value="ready">Klar til arbejde</option><option value="working">I gang</option><option value="paused">På pause</option><option value="waiting">Afventer</option><option value="high">Høj prioritet</option><option value="today">Deadline i dag</option><option value="overdue">Forfalden</option></select></label><input id="workSearch" class="form-control form-control-sm mb-2" type="search" placeholder="Søg efter sag, kunde, sagstype eller tags" aria-label="Søg i kø"><div id="workQueue"></div><button id="workMore" class="btn btn-sm btn-outline-secondary" hidden>Vis flere</button></div></div><div id="workCalendarPane"><div class="work-calendar-legend"><span><i style="background:color-mix(in srgb,var(--accent) 12%,var(--bg-card));border:1px solid var(--frame-border)" aria-hidden="true"></i>Allokeret · reserveret tid</span><span><i style="background:#7c3aed" aria-hidden="true"></i>Registreret · faktisk brugt tid</span></div><section id="workAbsenceSummary" class="card p-3 mb-2" aria-label="Fravær i den viste periode"></section><div id="workCalendar" class="card"></div></div></section>
<iframe id="workTeam" title="Medarbejderplanlægning" hidden loading="lazy"></iframe>
<section id="workCapacity" hidden></section><section id="workUnassigned" hidden aria-labelledby="unassignedHeading">
<div class="card">
<header class="card-header p-3 work-row"><div><h2 id="unassignedHeading" class="h5 mb-1">Sager der mangler en medarbejder</h2><p class="work-muted mb-0">Vælg, hvem der skal udføre arbejdet. Sagen kommer derefter i medarbejderens arbejdskø.</p></div>
<details class="work-help"><summary aria-label="Hvad betyder ikke allokeret?"><i class="bi bi-info-circle" aria-hidden="true"></i></summary><div>En sag er ikke allokeret, når ingen medarbejder har den i sin arbejdskø. Sagen kan godt have en ansvarlig: Den ansvarlige følger sagen, mens en tildelt medarbejder udfører arbejdet.</div></details>
</header>
<div class="card-body">
<div class="work-row gap-3 mb-3"><p class="small text-secondary mb-0"><strong>1.</strong> Find sagen <span class="mx-2" aria-hidden="true">→</span> <strong>2.</strong> Vælg medarbejder <span class="mx-2" aria-hidden="true">→</span> <strong>3.</strong> Tilføj til kø</p><button id="unassignedRefresh" type="button" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Opdatér listen</button></div>
<label for="unassignedSearch" class="form-label">Find en sag</label><input id="unassignedSearch" type="search" class="form-control mb-3" placeholder="Søg på sagsnummer, titel, kunde eller ansvarlig…">
<p id="unassignedStatus" role="status" aria-live="polite" class="small mb-2"></p>
<div id="unassignedError" role="alert" class="alert alert-danger" hidden></div>
<div id="unassignedList" aria-live="polite"></div>
<nav class="work-row mt-3" aria-label="Sider med ikke-allokerede sager"><button id="unassignedPrev" class="btn btn-sm btn-outline-secondary" type="button" disabled>Forrige</button><span id="unassignedPage" class="work-muted"></span><button id="unassignedNext" class="btn btn-sm btn-outline-secondary" type="button" disabled>Næste</button></nav>
</div>
</div>
</section>
<dialog id="workEventDialog"><form id="workEventForm"><h2 class="h5">Fast aftale</h2><input name="event_id" type="hidden"><input name="version" type="hidden"><label>Titel<input name="title" class="form-control" required maxlength="255"></label><label>Sag (valgfrit)<input name="case_search" type="search" list="workCaseSuggestions" class="form-control" placeholder="Søg efter sagsnummer, titel eller kunde"><datalist id="workCaseSuggestions"></datalist><input name="sag_id" type="hidden"></label><label>Deltagere<select name="user_ids" class="form-select" multiple required></select></label><label>Start<input name="starts_at" type="datetime-local" class="form-control" required></label><label>Slut<input name="ends_at" type="datetime-local" class="form-control" required></label><label>Type<select name="kind" class="form-select"><option value="work">Arbejde</option><option value="meeting">Møde</option><option value="internal">Intern aktivitet</option></select></label><label>Beskrivelse<textarea name="description" class="form-control"></textarea></label><div class="work-actions mt-3"><button class="btn btn-primary">Gem</button><button class="btn btn-outline-danger" type="button" id="workDeleteEvent" hidden>Annullér aftale</button><button class="btn btn-light" type="button" data-work-close>Luk</button></div><p class="work-dialog-error text-danger" role="alert"></p></form></dialog>
<dialog id="workAbsenceDialog"><form id="workAbsenceForm"><input name="absence_id" type="hidden"><input name="version" type="hidden"><h2 class="h5">Registrer planlagt fravær</h2><label>Medarbejder<select name="user_id" class="form-select" required></select></label><label>Type<select name="kind" class="form-select" required></select></label><label class="form-check"><input name="whole_days" type="checkbox" class="form-check-input"> Hele dage</label><label>Fra og med<input name="starts_at" type="datetime-local" class="form-control" required></label><label>Til og med<input name="ends_at" type="datetime-local" class="form-control" required></label><p class="small text-secondary">Ved hele dage medregnes både første og sidste dato. Delvise dage angives med klokkeslæt.</p><label>Privat note<textarea name="note" class="form-control"></textarea></label><div class="work-actions mt-3"><button class="btn btn-primary">Gem</button><button type="button" class="btn btn-outline-primary" id="workRegisterAbsence" hidden>Registrer som afholdt</button><button type="button" class="btn btn-outline-danger" id="workCancelAbsence" hidden>Annullér fravær</button><button class="btn btn-light" type="button" data-work-close>Luk</button></div><p class="work-dialog-error text-danger" role="alert"></p></form></dialog>
<dialog id="workNextDialog"><h2 class="h5">Næste opgave</h2><p id="workNextMessage"></p><div id="workNextActions" class="work-actions"></div><button class="btn btn-light mt-3" data-work-close>Luk</button></dialog>
</main>
{% endblock %}
{% block extra_js %}<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script><script src="/static/vendor/flatpickr/flatpickr.min.js"></script><script src="/static/vendor/flatpickr/da.js"></script><script src="/static/js/planning-dates.js?v=20261018-1"></script><script src="/static/js/planning-work.js?v=20261018-9"></script>{% endblock %}

View File

@ -550,11 +550,26 @@ class QuickRentCreateInput(BaseModel):
freight_price: float = Field(default=0, ge=0)
preparation_price: float = Field(default=0, ge=0)
operations_monthly_price: float = Field(gt=0)
initial_operations_months: int = Field(default=2, ge=1, le=12)
initial_operations_months: int = Field(default=0, ge=0, le=12)
notice_period_days: int = Field(default=30, ge=0)
created_by_user_id: Optional[int] = None
@router.get('/hardware/{hardware_id}/quick-rent/cases')
async def quick_rent_cases(hardware_id: int, customer_id: int = Query(..., gt=0), q: str = Query('', max_length=160)):
"""Customer-scoped open cases for the rental picker; existing APIs are unchanged."""
if not execute_query_single('SELECT id FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,)):
raise HTTPException(404, 'Hardware findes ikke')
term = '%' + q.strip() + '%'
rows = execute_query("""SELECT id, titel, status FROM sag_sager
WHERE customer_id=%s AND deleted_at IS NULL
AND LOWER(TRIM(COALESCE(status,''))) NOT IN
('lukket','closed','afsluttet','done','løst','resolved')
AND (titel ILIKE %s OR id::text ILIKE %s)
ORDER BY created_at DESC, id DESC LIMIT 101""", (customer_id, term, term)) or []
return {'cases': rows[:100], 'has_more': len(rows) > 100}
@router.get("/hardware/{hardware_id}/quick-rent/preview", response_model=Dict[str, Any])
async def quick_rent_preview(hardware_id: int, customer_id: int = Query(...), sag_id: int = Query(...)):
"""Preview whether quick-rent will reuse an existing subscription or create a new one."""
@ -650,11 +665,18 @@ async def list_assets(
status: str = Query("all"),
customer_id: Optional[int] = Query(default=None),
only_rental_enabled: bool = Query(default=False),
bmc_asset_category: Optional[str] = Query(default=None, pattern='^(rental|internal|unassigned)$'),
):
"""Alias endpoint for rental-focused asset listing."""
where = ["ha.deleted_at IS NULL"]
where = ["ha.deleted_at IS NULL", "ha.current_owner_type = 'bmc'"]
params: List[Any] = []
if bmc_asset_category == 'unassigned':
where.append('ha.bmc_asset_category IS NULL')
elif bmc_asset_category:
where.append('ha.bmc_asset_category = %s')
params.append(bmc_asset_category)
if status != "all":
where.append("COALESCE(ha.rental_status, 'ledig') = %s")
params.append(status)
@ -673,6 +695,7 @@ async def list_assets(
SELECT
ha.id,
ha.asset_type,
ha.bmc_asset_category,
ha.brand,
ha.model,
ha.serial_number,
@ -949,7 +972,7 @@ async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
"billing_direction": "forward",
"price_type": "manual",
"custom_price_override": True,
"first_invoice_policy": "next_cycle",
"first_invoice_policy": "start_date",
"invoice_merge_key": f"rental-{payload.customer_id}-{payload.sag_id}",
"notes": f"Quick rent created from hardware #{hardware_id}",
"line_items": [
@ -1019,7 +1042,8 @@ async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
raise HTTPException(status_code=400, detail="At least one startup/order line must have a price")
startup_lines = with_invoice_fee(startup_lines, payload.customer_id)
coverage_end = payload.start_date + relativedelta(months=payload.initial_operations_months)
coverage_end = (payload.start_date + relativedelta(months=payload.initial_operations_months)
if payload.initial_operations_months else None)
order_result = execute_query(
"""
INSERT INTO ordre_drafts (
@ -1050,7 +1074,7 @@ async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
f"Subscription: #{subscription_id}\n"
f"Asset: #{hardware_id}"
),
payload.start_date,
payload.start_date if payload.initial_operations_months else None,
coverage_end,
"forward",
[subscription_id],

View File

@ -15,7 +15,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection
from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection, atomic_transaction
from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings
@ -1682,16 +1682,39 @@ async def save_case_assignments(sag_id: int, payload: CaseAssignmentsUpdate, req
primary_user_id = user_ids[0]
user_id = _get_user_id_from_request(request)
execute_query("DELETE FROM sag_case_assignees WHERE sag_id=%s", (sag_id,))
for assignee_id in user_ids:
extended_planning=table_has_column('sag_case_assignees','queue_position')
with atomic_transaction():
before_case=execute_query_single("SELECT id,assigned_group_id,ansvarlig_bruger_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE", (sag_id,))
if not before_case:
raise HTTPException(404,'Sagen findes ikke')
before_users=execute_query('SELECT * FROM sag_case_assignees WHERE sag_id=%s ORDER BY user_id',(sag_id,)) or []
if extended_planning:
from app.modules.planner.backend.work import lock_queue,audit,notify_case
for target_id in sorted({r['user_id'] for r in before_users}|set(user_ids)):
lock_queue(target_id)
removed=[r['user_id'] for r in before_users if r['user_id'] not in user_ids]
if execute_query_single('SELECT 1 FROM planner_allocations WHERE sag_id=%s AND user_id=ANY(%s) AND deleted_at IS NULL AND ends_at>NOW() LIMIT 1',(sag_id,removed)):
raise HTTPException(409,'Flyt eller annullér medarbejderens fremtidige aftaler før allokeringen fjernes')
execute_query(
"INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id) VALUES (%s, %s, %s)",
(sag_id, assignee_id, user_id),
"DELETE FROM sag_case_assignees WHERE sag_id=%s AND NOT (user_id=ANY(%s))",
(sag_id, user_ids),
)
execute_query(
"UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
(payload.group_id, primary_user_id, sag_id),
)
for assignee_id in user_ids:
execute_query(
"INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id) VALUES (%s, %s, %s) ON CONFLICT (sag_id,user_id) DO NOTHING",
(sag_id, assignee_id, user_id),
)
execute_query(
"UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
(payload.group_id, primary_user_id, sag_id),
)
if extended_planning and user_id is not None:
after_users=execute_query('SELECT * FROM sag_case_assignees WHERE sag_id=%s ORDER BY user_id',(sag_id,)) or []
record=audit({'id':user_id},'case_assign','case',sag_id,
{'case':before_case,'assignees':before_users},
{'group_id':payload.group_id,'responsible_id':primary_user_id,'assignees':after_users},sag_id)
added=set(user_ids)-{r['user_id'] for r in before_users}
notify_case({'id':user_id},sag_id,f'audit:{record}','Du er blevet allokeret til sagen.',added)
return await get_case_assignments(sag_id, payload.group_id)
@router.post("/sag/{sag_id}/assign-with-reason", dependencies=[Depends(case_edit_access)])

View File

@ -0,0 +1,86 @@
"""Explicit case access lists supplement the existing general case permissions."""
from fastapi import APIRouter,Depends,HTTPException
from pydantic import BaseModel,Field
from app.core.auth_dependencies import get_current_user
from app.core.case_visibility import require_case
from app.core.database import atomic_transaction,execute_query,execute_query_single
from app.modules.planner.backend.work import audit
router=APIRouter()
def can_manage(user):
return bool(user.get('is_superadmin') or 'cases.manage_confidential' in set(user.get('permissions') or []))
def require_read(user,sag_id):
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'cases.view','tickets.view'}):
raise HTTPException(403,'Du har ikke adgang til sager')
require_case(user,sag_id)
if not execute_query_single('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)):
raise HTTPException(404,'Sagen findes ikke')
def state(sag_id):
row=execute_query_single('SELECT version,restricted FROM sag_case_visibility WHERE sag_id=%s',(sag_id,))
return {'restricted':row['restricted'] if row else False,'version':row['version'] if row else 0,
'user_ids':[r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_visibility_users WHERE sag_id=%s',(sag_id,)) or []],
'group_ids':[r['group_id'] for r in execute_query('SELECT group_id FROM sag_case_visibility_groups WHERE sag_id=%s',(sag_id,)) or []]}
@router.get('/sag/{sag_id}/visibility')
def get_visibility(sag_id:int,user:dict=Depends(get_current_user)):
require_read(user,sag_id)
result=state(sag_id)
result['can_manage']=can_manage(user)
if result['can_manage']:
result['users']=execute_query('SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users WHERE is_active ORDER BY name') or []
result['groups']=execute_query('SELECT id,name FROM groups ORDER BY name') or []
else:
result.pop('user_ids')
result.pop('group_ids')
return result
class VisibilityInput(BaseModel):
restricted:bool
version:int=Field(ge=0)
user_ids:list[int]=Field(default_factory=list,max_length=1000)
group_ids:list[int]=Field(default_factory=list,max_length=1000)
@router.put('/sag/{sag_id}/visibility')
def update_visibility(sag_id:int,payload:VisibilityInput,user:dict=Depends(get_current_user)):
require_read(user,sag_id)
if not can_manage(user):
raise HTTPException(403,'Du må ikke ændre fortrolighed')
ids,groups=sorted(set(payload.user_ids)),sorted(set(payload.group_ids))
if payload.restricted and not ids and not groups:
raise HTTPException(400,'Vælg mindst én medarbejder eller gruppe')
if payload.restricted and not user.get('is_superadmin') and user['id'] not in ids:
own_groups=execute_query('SELECT group_id FROM user_groups WHERE user_id=%s AND group_id=ANY(%s)',(user['id'],groups)) or []
if not own_groups:
raise HTTPException(400,'Du skal bevare din egen adgang til sagen')
with atomic_transaction():
execute_query('SELECT id FROM sag_sager WHERE id=%s FOR UPDATE',(sag_id,))
old=state(sag_id)
if old['version']!=payload.version:
raise HTTPException(409,'Adgangen er ændret. Genindlæs.')
if len(execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) AND is_active',(ids,)) or [])!=len(ids):
raise HTTPException(400,'Ukendt eller inaktiv medarbejder')
if len(execute_query('SELECT id FROM groups WHERE id=ANY(%s)',(groups,)) or [])!=len(groups):
raise HTTPException(400,'Ukendt gruppe')
execute_query('''INSERT INTO sag_case_visibility(sag_id,updated_by,restricted) VALUES(%s,%s,%s)
ON CONFLICT(sag_id) DO UPDATE SET version=sag_case_visibility.version+1,restricted=EXCLUDED.restricted,
updated_by=EXCLUDED.updated_by,updated_at=NOW()''',(sag_id,user['id'],payload.restricted),fetch=False)
execute_query('DELETE FROM sag_case_visibility_users WHERE sag_id=%s',(sag_id,),fetch=False)
execute_query('DELETE FROM sag_case_visibility_groups WHERE sag_id=%s',(sag_id,),fetch=False)
if payload.restricted:
for uid in ids:
execute_query('INSERT INTO sag_case_visibility_users(sag_id,user_id) VALUES(%s,%s)',(sag_id,uid),fetch=False)
for gid in groups:
execute_query('INSERT INTO sag_case_visibility_groups(sag_id,group_id) VALUES(%s,%s)',(sag_id,gid),fetch=False)
new=state(sag_id)
audit(user,'visibility_update','case',sag_id,old,new,sag_id)
return new

View File

@ -380,6 +380,10 @@ async def sager_liste(
fallback_query += " ORDER BY s.created_at DESC"
sager = execute_query(fallback_query, tuple(fallback_params)) or []
from app.core.case_visibility import hidden_case_ids
hidden = hidden_case_ids({'id':request.state.user_id,
'is_superadmin':getattr(request.state,'case_superadmin',False)}) if getattr(request.state,'user_id',None) is not None else set()
sager = [s for s in (sager or []) if s['id'] not in hidden]
# Fetch relations for all cases
relations_query = """
SELECT
@ -400,6 +404,8 @@ async def sager_liste(
# Build relations map: {sag_id: [list of related sag_ids]}
relations_map = {}
for rel in all_relations or []:
if rel['kilde_sag_id'] in hidden or rel['målsag_id'] in hidden:
continue
if rel.get('målsag_id') is not None:
child_ids.add(rel['målsag_id'])
# Add forward relation
@ -1307,6 +1313,12 @@ async def sag_detaljer_v4(request: Request, sag_id: int):
template_name = "modules/sag/templates/detail_v4.html"
if request.url.path.rstrip("/").endswith("/v5"):
template_name = "modules/sag/templates/detail_v5.html"
if getattr(request.state,'user_id',None) is not None:
from app.core.case_visibility import hidden_case_ids,filter_data
hidden=hidden_case_ids({'id':request.state.user_id,'is_superadmin':getattr(request.state,'case_superadmin',False)})
relationer=filter_data(relationer,hidden)
relation_tree=filter_data(relation_tree,hidden)
related_case_options=filter_data(related_case_options,hidden,'cases')
response = templates.TemplateResponse(template_name, {
"request": request,
"case": sag,

View File

@ -7182,6 +7182,11 @@
const host = document.getElementById('casePlannerWeek');
if (!host) return;
const start = embeddedPlannerWeekStart(embeddedPlannerWeekAnchor);
if (document.querySelector('.case-v5') && window.renderCaseWeekCalendar) {
window.renderCaseWeekCalendar(host, start, embeddedPlannerItems);
renderEmbeddedPlannerSubcaseCalendar();
return;
}
const end = new Date(start);
end.setDate(end.getDate() + 6);
document.getElementById('casePlannerWeekLabel').textContent = `${start.toLocaleDateString('da-DK', {day:'numeric', month:'short'})} – ${end.toLocaleDateString('da-DK', {day:'numeric', month:'short', year:'numeric'})}`;
@ -7191,7 +7196,7 @@
day.setDate(day.getDate() + index);
const key = embeddedPlannerDateKey(day);
const items = embeddedPlannerItems.filter((item) => embeddedPlannerDateKey(item.starts_at) === key);
return `<div class="case-planner-day ${key === todayKey ? 'is-today' : ''}" onclick="openEmbeddedPlannerSlot('${key}')"><div class="case-planner-day-head"><span>${day.toLocaleDateString('da-DK', {weekday:'short'})}</span><span><strong>${day.getDate()}</strong> <i class="bi bi-plus-circle text-primary ms-1"></i></span></div>${items.length ? items.map((item) => { const startTime = new Date(item.starts_at); const endTime = new Date(item.ends_at); return `<div class="case-planner-calendar-item"><strong>${startTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}–${endTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}</strong><div>${escapeHtml(item.user_name || 'Ukendt')}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`; }).join('') : '<div class="small text-muted opacity-50 mt-3">Klik for at planlægge</div>'}</div>`;
return `<div class="case-planner-day ${key === todayKey ? 'is-today' : ''}" onclick="openEmbeddedPlannerSlot('${key}')"><div class="case-planner-day-head"><span>${day.toLocaleDateString('da-DK', {weekday:'short'})}</span><span><strong>${day.getDate()}</strong> <i class="bi bi-plus-circle text-primary ms-1"></i></span></div>${items.length ? items.map((item) => { const startTime = new Date(item.starts_at); const endTime = new Date(item.ends_at); return `<div class="case-planner-calendar-item"><strong>${startTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}–${endTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}</strong><div>${escapeHtml(item.user_name || 'Ukendt')}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}${document.querySelector('.case-v5') && !item.locked ? `<button type="button" class="case-slot-resize" data-allocation-id="${Number(item.id)}" data-version="${Number(item.version)}" data-start="${escapeHtml(item.starts_at)}" data-end="${escapeHtml(item.ends_at)}" aria-label="Ændr varighed: træk op eller ned, eller brug piletaster" title="Træk op eller ned for at ændre varighed">⋮ Træk for at ændre tid</button>` : ''}</div>`; }).join('') : '<div class="small text-muted opacity-50 mt-3">Klik for at planlægge</div>'}</div>`;
}).join('');
renderEmbeddedPlannerSubcaseCalendar();
}
@ -10757,24 +10762,98 @@
</div>
<style>
#subscription{--sub-ink:#17324a;--sub-blue:#0e608f;--sub-cyan:#1aa5b6;--sub-line:#dce8ef}
.change-line-editor{margin-top:.75rem;border:1px solid #dce7ed;border-radius:13px;background:#f8fbfd;padding:.7rem}.change-line-editor table{min-width:760px}.change-line-editor th{font-size:.62rem;text-transform:uppercase;letter-spacing:.05em;color:#718391}.change-line-editor td{padding:.3rem}.change-live-preview{margin-top:.65rem;border:1px solid #bcdce8;border-radius:13px;background:linear-gradient(120deg,#f3fbfe,#eef7fb);padding:.8rem}.change-live-preview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.55rem}.change-preview-value{font-weight:800;color:#173f58}.change-preview-lines{border-top:1px solid #d5e7ee;margin-top:.65rem;padding-top:.55rem;font-size:.74rem}@media(max-width:767px){.change-live-preview-grid{grid-template-columns:1fr 1fr}}
#subscription .card{border-radius:18px;border-color:var(--sub-line)!important;box-shadow:0 8px 26px rgba(21,60,91,.06)}
.agreement-hero{position:relative;overflow:hidden;border-radius:20px;padding:1.35rem 1.5rem;color:#fff;background:linear-gradient(120deg,#0b3d61,#0e7192 62%,#19a5ac);box-shadow:0 16px 38px rgba(14,96,143,.2)}
.agreement-hero:after{content:"";position:absolute;width:180px;height:180px;border:42px solid rgba(255,255,255,.08);border-radius:50%;right:-45px;top:-80px}.agreement-hero-kicker{font-size:.68rem;letter-spacing:.13em;text-transform:uppercase;color:#a7eef2;font-weight:800}.agreement-hero-value{font-size:1.65rem;font-weight:850;letter-spacing:-.04em}
.agreement-metric{min-width:115px;border-left:1px solid rgba(255,255,255,.22);padding-left:1rem}.agreement-metric-label{font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,.65);font-weight:750}
.agreement-change-banner{border:0!important;border-left:4px solid #e0a11a!important;border-radius:14px!important;background:#fff9e8!important;color:#59430d!important}
.agreement-card{overflow:hidden;transition:.2s ease;background:var(--theme-surface, #fff)}.agreement-card:hover{transform:translateY(-2px);box-shadow:0 14px 34px rgba(21,60,91,.11)!important}.agreement-card:has(.subscription-change-select:checked){border-color:#1a94b0!important;box-shadow:0 0 0 3px rgba(26,148,176,.12)!important}.agreement-card-accent{height:4px;background:linear-gradient(90deg,#0e608f,#1aa5b6)}
.agreement-card .form-control,.agreement-card .form-select{border-radius:10px;border-color:#dce6ed;background:#f8fbfd}.agreement-card:has(.subscription-change-select:checked) .form-control,.agreement-card:has(.subscription-change-select:checked) .form-select{background:var(--theme-surface, #fff);border-color:#a9cedc}
.agreement-toolbar{border:1px solid var(--sub-line);background:var(--theme-surface, #fff);border-radius:15px;padding:.65rem;box-shadow:0 7px 22px rgba(21,60,91,.05)}.agreement-search{border:0;background:#f2f7fa;border-radius:10px}.agreement-filter{border:0;border-radius:999px;padding:.45rem .75rem;background:#edf3f6;color:#607284;font-size:.75rem;font-weight:750}.agreement-filter.active{background:#17324a;color:#fff}.agreement-details{border-top:1px solid #e8eff3;margin-top:1rem;padding-top:.8rem}.agreement-details summary{cursor:pointer;color:#376176;font-size:.78rem;font-weight:750;list-style:none}.agreement-details summary::-webkit-details-marker{display:none}.agreement-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.55rem;margin-top:.8rem}.agreement-fact{background:#f6fafc;border-radius:10px;padding:.6rem}.agreement-fact-label{font-size:.62rem;text-transform:uppercase;letter-spacing:.06em;color:#80909f;font-weight:750}.agreement-fact-value{font-size:.78rem;color:#294052;font-weight:700;overflow-wrap:anywhere}.agreement-lines{font-size:.76rem}.agreement-lines th{color:#718190;font-size:.65rem;text-transform:uppercase;letter-spacing:.05em}.agreement-no-results{border:1px dashed #b8cfda;border-radius:16px;padding:2.5rem;text-align:center;background:#f8fbfc}
#subscription>.row>.col-lg-8>.card{overflow:visible!important}#subscription>.row>.col-lg-8>.card>.card-header{border-radius:10px 10px 0 0}.subscription-lines-table{overflow:visible!important}.subscription-lines-table table,.subscription-lines-table tbody,.subscription-lines-table tr,.subscription-lines-table td{overflow:visible}.subscription-lines-table tbody tr:focus-within{position:relative;z-index:20}.subscription-product-picker{position:relative;min-width:260px;z-index:1}.subscription-product-picker.open{z-index:1070}.subscription-product-picker .subscriptionProductSearch{padding-left:2rem;background:var(--theme-surface, #fff)}.subscription-product-picker .picker-search-icon{position:absolute;z-index:3;left:.7rem;top:.55rem;color:#6d8190;pointer-events:none}.subscription-product-picker .picker-menu{display:none;position:absolute;z-index:1075;top:calc(100% + 5px);left:0;width:max(100%,380px);max-width:520px;max-height:320px;overflow-y:auto;background:var(--theme-surface, #fff);border:1px solid #cbdce6;border-radius:12px;box-shadow:0 18px 45px rgba(20,49,70,.2);padding:.35rem}.subscription-product-picker.open .picker-menu{display:block}.subscription-product-picker .picker-option{display:block;width:100%;border:0;background:transparent;text-align:left;border-radius:9px;padding:.55rem .65rem;color:#243c50}.subscription-product-picker .picker-option:hover,.subscription-product-picker .picker-option.active{background:#eaf4ff;color:#075dc7}.subscription-product-picker .picker-option-name{display:block;font-weight:700}.subscription-product-picker .picker-option-meta{display:block;margin-top:.1rem;font-size:.7rem;color:#718391}.subscription-product-picker .picker-empty{padding:1rem;text-align:center;color:#718391;font-size:.78rem}@media(max-width:767.98px){.subscription-lines-table{overflow-x:auto!important;overflow-y:visible!important}.subscription-product-picker .picker-menu{left:0;width:min(380px,calc(100vw - 2rem));max-width:none}}
.subscription-help{display:inline-grid;place-items:center;width:17px;height:17px;margin-left:.3rem;padding:0;border:0;border-radius:50%;background:#e3f1f8;color:#17648a;font-size:.68rem;vertical-align:middle;cursor:help}.subscription-help:hover,.subscription-help:focus{background:#17648a;color:#fff;outline:0;box-shadow:0 0 0 3px rgba(23,100,138,.14)}.tooltip.subscription-help-tooltip .tooltip-inner{max-width:320px;text-align:left;padding:.65rem .75rem;line-height:1.4}
.subscription-form-section{border:1px solid #dbe8ef;border-radius:16px;background:linear-gradient(145deg,#fbfdff,#f4f9fc);padding:1.1rem}.subscription-form-section-title{display:flex;align-items:center;gap:.65rem;margin-bottom:1rem;color:#24445a}.subscription-form-section-icon{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:#dff1f7;color:#126485}.subscription-auto-value{background:#edf5f8!important;color:#526b7a}.subscription-field-disabled{opacity:.48}.subscription-advanced{border-top:1px solid #dce8ef;margin-top:1rem;padding-top:.75rem}.subscription-advanced summary{cursor:pointer;color:#37667b;font-weight:700;font-size:.82rem}.subscription-binding-override{border-left:3px solid #b8d7e4;padding-left:.75rem}.subscription-inline-note{font-size:.72rem;color:#728494;margin-top:.3rem}
.invoice-preview-column{display:block}.invoice-preview-sticky{position:sticky;top:1rem}.invoice-preview-shell{border:1px solid #cfdee7;border-radius:18px;background:#eaf2f6;padding:.75rem;box-shadow:0 14px 34px rgba(30,64,99,.11)}.invoice-preview-tabs{display:flex;gap:.35rem;margin-bottom:.65rem}.invoice-preview-tab{flex:1;border:0;border-radius:10px;padding:.55rem;background:transparent;color:#667b8c;font-size:.78rem;font-weight:750}.invoice-preview-tab.active{background:var(--theme-surface, #fff);color:var(--theme-text, #145c80);box-shadow:0 3px 10px rgba(30,64,99,.1)}.invoice-paper{position:relative;min-height:440px;border-radius:12px;background:var(--theme-surface, #fff);padding:1.15rem;box-shadow:0 5px 18px rgba(31,55,74,.1);color:var(--theme-text, #263b4b)}.invoice-paper:after{content:"FORHÅNDSVISNING";position:absolute;top:47%;left:8%;transform:rotate(-28deg);font-size:1.55rem;font-weight:900;letter-spacing:.1em;color:rgba(37,100,132,.055);pointer-events:none}.invoice-preview-kicker{font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;color:#7b8d9b;font-weight:800}.invoice-preview-number{font-size:1.3rem;font-weight:850;color:#173e58}.invoice-preview-meta{font-size:.72rem;color:#657887}.invoice-preview-lines{font-size:.7rem;margin-top:1rem}.invoice-preview-lines th{padding:.4rem .2rem;border-bottom:1px solid #dbe5eb;color:#748694;text-transform:uppercase;font-size:.58rem;letter-spacing:.05em}.invoice-preview-lines td{padding:.5rem .2rem;border-bottom:1px solid #edf2f5;vertical-align:top}.invoice-preview-total{border-top:2px solid #244d66;padding-top:.7rem;margin-top:.7rem}.invoice-preview-one-time{display:inline-block;margin-top:.15rem;border-radius:5px;background:#e8f5fa;color:#16708e;padding:.1rem .3rem;font-size:.58rem;font-weight:750}.invoice-preview-empty{padding:2rem .5rem;text-align:center;color:#81919e;font-size:.75rem}#subscription:has(#subscriptionCreateForm.d-none) .invoice-preview-column{display:none}@media(max-width:991.98px){.invoice-preview-sticky{position:static}.invoice-paper{min-height:0}}
.agreement-number{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--theme-muted, #718096);font-weight:750}.agreement-name{font-size:1rem;color:var(--sub-ink);font-weight:800}.agreement-price{font-size:1.35rem;color:var(--sub-ink);font-weight:850;letter-spacing:-.03em}
.agreement-line-chip{display:inline-flex;gap:.35rem;align-items:center;border:1px solid #dce7ee;border-radius:999px;padding:.35rem .6rem;background:#f6fafc;color:#506477;font-size:.73rem;font-weight:650}.agreement-timeline{position:relative;padding-left:1.25rem}.agreement-timeline:before{content:"";position:absolute;left:.3rem;top:.4rem;bottom:.4rem;width:2px;background:#dce8ef}.agreement-event{position:relative;padding:0 0 1rem .5rem}.agreement-event:before{content:"";position:absolute;left:-1.02rem;top:.3rem;width:9px;height:9px;border-radius:50%;background:#19a5b6;box-shadow:0 0 0 4px #e3f6f8}.agreement-event-title{font-weight:750;color:#2a4054}.agreement-event-meta{font-size:.72rem;color:#7b8b9b}
.change-case-hero{border-radius:18px;background:linear-gradient(120deg,#eef8fc,#f4fbfb);border:1px solid #cae5ec;padding:1.25rem}.change-diff-row{display:grid;grid-template-columns:130px 1fr 28px 1fr;gap:.65rem;align-items:center;padding:.65rem 0;border-bottom:1px solid #edf2f5}.change-diff-row:last-child{border-bottom:0}.change-diff-label{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:#7c8b9a;font-weight:750}.change-diff-before{color:#687889;text-decoration:line-through}.change-diff-after{color:#146b7d;font-weight:750}
@media(max-width:991px){.agreement-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:767px){.change-diff-row{grid-template-columns:1fr}.change-diff-row .bi-arrow-right{transform:rotate(90deg)}.agreement-facts{grid-template-columns:1fr}}
/* Subscription uses the same surfaces, frames and controls as other case modules. */
#subscription { --sub-ink: var(--text-primary); --sub-line: var(--frame-border-strong); }
#subscription .agreement-hero, #subscription .change-case-hero {
padding: 1rem; border: 2px solid var(--frame-border-strong); border-left: 4px solid var(--accent);
border-radius: var(--border-radius); background: var(--bg-card); color: var(--text-primary); box-shadow: var(--frame-shadow);
}
#subscription .agreement-hero-kicker, #subscription .agreement-metric-label, #subscription .agreement-number,
#subscription .agreement-fact-label, #subscription .agreement-event-meta, #subscription .subscription-inline-note,
#subscription .change-diff-label { font-size: .75rem; color: var(--text-secondary); font-weight: 500; }
#subscription .agreement-hero-value { font-size: 1.2rem; font-weight: 600; margin: .2rem 0; }
#subscription .agreement-metric { min-width: 100px; border-left: 1px solid var(--sub-line); padding-left: 1rem; }
#subscription .agreement-metric .h4 { font-size: 1.15rem; }
#subscription .agreement-card-accent { display: none; }
#subscription .agreement-card:has(.subscription-change-select:checked) { outline: 2px solid var(--accent); outline-offset: -2px; }
#subscription .agreement-name, #subscription .agreement-price, #subscription .agreement-fact-value,
#subscription .change-preview-value, #subscription .agreement-event-title { color: var(--text-primary); font-weight: 600; }
#subscription .agreement-name { font-size: .95rem; }
#subscription .agreement-price { font-size: 1.15rem; }
#subscription .agreement-toolbar, #subscription .subscription-form-section, #subscription .change-line-editor,
#subscription .change-live-preview, #subscription .invoice-preview-shell {
border: 1px solid var(--sub-line); border-radius: var(--border-radius); background: var(--bg-card); padding: .85rem;
}
#subscription .agreement-filter { border: 1px solid var(--sub-line); border-radius: .375rem; padding: .35rem .65rem; background: var(--bg-card); color: var(--text-primary); font-size: .8rem; }
#subscription .agreement-filter.active { background: var(--accent); border-color: var(--accent); color: #fff; }
#subscription .agreement-filter:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
#subscription .agreement-details, #subscription .subscription-advanced { border-top: 1px solid var(--sub-line); margin-top: 1rem; padding-top: .75rem; }
#subscription summary { cursor: pointer; color: var(--text-primary); font-size: .85rem; font-weight: 500; }
#subscription .agreement-facts, #subscription .change-live-preview-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .75rem; margin-top: .75rem; }
#subscription .agreement-fact { padding: .5rem 0; border-bottom: 1px solid var(--sub-line); }
#subscription .agreement-fact-value { font-size: .85rem; overflow-wrap: anywhere; }
#subscription .agreement-lines, #subscription .change-preview-lines { font-size: .8rem; }
#subscription .change-preview-lines { border-top: 1px solid var(--sub-line); margin-top: .65rem; padding-top: .55rem; }
#subscription .agreement-no-results { border: 1px dashed var(--sub-line); border-radius: var(--border-radius); padding: 1.5rem; text-align: center; color: var(--text-secondary); }
#subscription > .row > .col-lg-8 > .card { overflow: visible; }
#subscription .subscription-lines-table { overflow: visible; }
#subscription .subscription-lines-table table, #subscription .subscription-lines-table tbody,
#subscription .subscription-lines-table tr, #subscription .subscription-lines-table td { overflow: visible; }
#subscription .subscription-lines-table tbody tr:focus-within { position: relative; z-index: 20; }
#subscription .change-line-editor table { min-width: 760px; }
#subscription .subscription-product-picker { position: relative; min-width: 260px; z-index: 1; }
#subscription .subscription-product-picker.open { z-index: 1070; }
#subscription .subscriptionProductSearch { padding-left: 2rem; }
#subscription .picker-search-icon { position: absolute; z-index: 3; left: .7rem; top: .55rem; color: var(--text-secondary); pointer-events: none; }
#subscription .picker-menu { display: none; position: absolute; z-index: 1075; top: calc(100% + 5px); left: 0; width: max(100%,380px); max-width: 520px; max-height: 320px; overflow-y: auto; background: var(--bg-card); border: 1px solid var(--sub-line); border-radius: var(--border-radius); box-shadow: var(--frame-shadow); padding: .35rem; }
#subscription .subscription-product-picker.open .picker-menu { display: block; }
#subscription .picker-option { display: block; width: 100%; border: 0; background: transparent; text-align: left; border-radius: .375rem; padding: .55rem .65rem; color: var(--text-primary); }
#subscription .picker-option:hover, #subscription .picker-option.active { background: color-mix(in srgb,var(--accent) 10%,var(--bg-card)); }
#subscription .picker-option-name { display: block; font-weight: 600; }
#subscription .picker-option-meta, #subscription .picker-empty { color: var(--text-secondary); font-size: .75rem; }
#subscription .picker-option-meta { display: block; margin-top: .1rem; }
#subscription .picker-empty { padding: 1rem; text-align: center; }
#subscription .subscription-help { display: inline-grid; place-items: center; width: 17px; height: 17px; margin-left: .3rem; padding: 0; border: 1px solid var(--sub-line); border-radius: 50%; background: var(--bg-card); color: var(--accent); font-size: .68rem; vertical-align: middle; cursor: help; }
#subscription .subscription-help:focus-visible { outline: 2px solid var(--accent); }
.tooltip.subscription-help-tooltip .tooltip-inner { max-width: 320px; text-align: left; padding: .65rem .75rem; line-height: 1.4; }
#subscription .subscription-form-section-title { display: flex; align-items: center; gap: .65rem; margin-bottom: 1rem; color: var(--text-primary); }
#subscription .subscription-form-section-icon { display: grid; place-items: center; width: 30px; height: 30px; color: var(--accent); }
#subscription .subscription-auto-value { color: var(--text-secondary); }
#subscription .subscription-field-disabled { opacity: .5; }
#subscription .subscription-binding-override { border-left: 3px solid var(--sub-line); padding-left: .75rem; }
#subscription .subscription-inline-note { margin-top: .3rem; }
#subscription .invoice-preview-sticky { position: sticky; top: 1rem; }
#subscription .invoice-preview-tabs { display: flex; gap: .35rem; margin-bottom: .65rem; }
#subscription .invoice-preview-tab { flex: 1; border: 1px solid var(--sub-line); border-radius: .375rem; padding: .55rem; background: var(--bg-card); color: var(--text-secondary); font-size: .8rem; }
#subscription .invoice-preview-tab.active { color: var(--accent); border-color: var(--accent); }
#subscription .invoice-paper { min-height: 440px; background: var(--bg-card); padding: 1rem; color: var(--text-primary); }
#subscription .invoice-preview-kicker, #subscription .invoice-preview-meta, #subscription .invoice-preview-empty { font-size: .75rem; color: var(--text-secondary); }
#subscription .invoice-preview-number { font-size: 1.15rem; font-weight: 600; }
#subscription .invoice-preview-lines { font-size: .75rem; margin-top: 1rem; }
#subscription .invoice-preview-lines th, #subscription .invoice-preview-lines td { padding: .4rem .2rem; border-bottom: 1px solid var(--sub-line); }
#subscription .invoice-preview-total { border-top: 2px solid var(--accent); padding-top: .7rem; margin-top: .7rem; }
#subscription .invoice-preview-one-time { font-size: .7rem; color: var(--text-secondary); }
#subscription .invoice-preview-empty { padding: 2rem .5rem; text-align: center; }
#subscription:has(#subscriptionCreateForm.d-none) .invoice-preview-column { display: none; }
#subscription:has(#subscriptionCreateForm.d-none) > .row > .col-lg-8 { flex: 0 0 100%; width: 100%; max-width: 100%; }
#subscription .agreement-line-chip { display: inline-flex; gap: .35rem; align-items: center; border: 1px solid var(--sub-line); border-radius: .375rem; padding: .3rem .5rem; color: var(--text-secondary); font-size: .75rem; }
#subscription .agreement-timeline { border-left: 2px solid var(--sub-line); padding-left: 1rem; }
#subscription .agreement-event { padding-bottom: .8rem; }
#subscription .agreement-event-meta { font-size: .75rem; }
#subscription .change-diff-row { display: grid; grid-template-columns: 130px 1fr 28px 1fr; gap: .65rem; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--sub-line); }
#subscription .change-diff-before { color: var(--text-secondary); text-decoration: line-through; }
#subscription .change-diff-after { color: var(--text-primary); font-weight: 600; }
@media(max-width:991px) {
#subscription .agreement-facts { grid-template-columns: repeat(2,minmax(0,1fr)); }
#subscription .invoice-preview-sticky { position: static; }
#subscription .invoice-paper { min-height: 0; }
}
@media(max-width:767px) {
#subscription .change-diff-row, #subscription .agreement-facts { grid-template-columns: 1fr; }
#subscription .change-live-preview-grid { grid-template-columns: 1fr 1fr; }
#subscription .subscription-lines-table { overflow-x: auto; }
#subscription .picker-menu { width: min(380px,calc(100vw - 2rem)); max-width: none; }
}
</style>
<!-- Subscription Tab -->
<div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown">
@ -19136,7 +19215,7 @@
const timeCount = Math.max(initialTimeCount, loadedTimeCount, timeDomCount);
_setCaseTabCountBadge('timetrackingTabCountBadge', timeCount);
const subscriptionCount = _countRows('#subscriptionItemsBody');
const subscriptionCount = Number(window.caseSubscriptionCount || 0);
_setCaseTabCountBadge('subscriptionTabCountBadge', subscriptionCount);
const reminderCount = document.querySelectorAll('#remindersList .list-group-item').length;
@ -22340,12 +22419,25 @@
actions.innerHTML = buttons.join(' ');
}
function updateSubscriptionTabCount(payload) {
const subscriptions = [
...(payload.current_subscriptions || []),
...(payload.upcoming_subscriptions || []),
...(payload.ended_subscriptions || [])
];
window.caseSubscriptionCount = new Set(subscriptions.map(item => String(item.id))).size;
_setCaseTabCountBadge('subscriptionTabCountBadge', window.caseSubscriptionCount);
}
async function loadSubscriptionForCase() {
try {
const changeRes = await fetch(`/api/v1/sag-subscriptions/change-requests/by-case/${subscriptionCaseId}`);
if (changeRes.ok) {
const changePayload = await changeRes.json();
if (changePayload.change_request) {
const ids = new Set((changePayload.change_request.items || []).map(item => String(item.subscription_id)));
window.caseSubscriptionCount = ids.size || 1;
_setCaseTabCountBadge('subscriptionTabCountBadge', window.caseSubscriptionCount);
renderSubscriptionChangeCase(changePayload);
setModuleContentState('subscription', true);
return;
@ -22356,7 +22448,8 @@
throw new Error('Kunne ikke hente abonnement');
}
const payload = await res.json();
if (!(payload.current_subscriptions || []).length && !(payload.upcoming_subscriptions || []).length) {
updateSubscriptionTabCount(payload);
if (!window.caseSubscriptionCount) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return;
@ -22385,7 +22478,7 @@
}
function subscriptionEventLabel(eventType) {
return {subscription_created:'Abonnement oprettet',change_draft_saved:'Ændringskladde gemt',change_submitted:'Ændring indsendt',change_approved:'Ændring godkendt',change_applied:'Ændring anvendt',change_failed:'Ændring fejlede',subscription_expired:'Abonnement udløbet'}[eventType] || String(eventType || '').replaceAll('_', ' ');
return {subscription_created:'Abonnement oprettet',change_draft_saved:'Ændringskladde gemt',change_saved_directly:'Ændring gemt',change_submitted:'Ændring indsendt',change_approved:'Ændring godkendt',change_applied:'Ændring anvendt',change_failed:'Ændring fejlede',subscription_expired:'Abonnement udløbet'}[eventType] || String(eventType || '').replaceAll('_', ' ');
}
function agreementSearchText(subscription) {
@ -22515,27 +22608,27 @@
const totalValue = subscriptions.reduce((sum, item) => sum + Number(item.price || 0), 0);
const changePanel = openChanges.map(change => `
<div class="alert ${['failed','partially_applied'].includes(change.status) ? 'alert-danger' : 'agreement-change-banner'} d-flex flex-column flex-md-row gap-2 justify-content-between align-items-md-center">
<span><i class="bi ${['failed','partially_applied'].includes(change.status) ? 'bi-exclamation-octagon' : 'bi-hourglass-split'} me-2"></i><strong>${change.status === 'approved_scheduled' ? 'Planlagt ændring' : change.status === 'pending' ? 'Afventer godkendelse' : change.status === 'draft' ? 'Ændringskladde' : 'Kræver handling'}</strong>
<span><i class="bi ${['failed','partially_applied'].includes(change.status) ? 'bi-exclamation-octagon' : 'bi-hourglass-split'} me-2"></i><strong>${change.status === 'approved_scheduled' ? 'Planlagt ændring' : change.status === 'pending' ? 'Afventer gemning' : change.status === 'draft' ? 'Ændringskladde' : 'Kræver handling'}</strong>
<span class="opacity-75"> · ikraft ${formatSubscriptionDate(change.effective_date)} · ${(change.items || []).length} abonnementer</span></span>
<a class="btn btn-sm btn-outline-dark rounded-pill px-3" href="/sag/${change.change_sag_id}/v3">Åbn undersag <i class="bi bi-arrow-right ms-1"></i></a>
</div>`).join('');
overview.innerHTML = `
<div class="agreement-hero mb-3"><div class="position-relative d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center" style="z-index:1"><div><div class="agreement-hero-kicker">Aktuel abonnementsaftale</div><div class="agreement-hero-value">${payload.agreement_status}</div><div class="small text-white-50">Hovedsagen er den levende kontrakt — kommende ændringer vises separat.</div></div><div class="d-flex gap-4"><div class="agreement-metric"><div class="agreement-metric-label">Aktuelle</div><div class="h4 mb-0 fw-bold">${subscriptions.length}</div></div><div class="agreement-metric"><div class="agreement-metric-label">Samlet værdi</div><div class="h4 mb-0 fw-bold">${formatSubscriptionCurrency(totalValue)}</div></div><div class="agreement-metric"><div class="agreement-metric-label">Åbne ændringer</div><div class="h4 mb-0 fw-bold">${openChanges.length}</div></div></div></div></div>
<div class="agreement-hero mb-3"><div class="position-relative d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center" style="z-index:1"><div><div class="agreement-hero-kicker">Aktuel abonnementsaftale</div><div class="agreement-hero-value">${payload.agreement_status}</div><div class="small text-muted">Hovedsagen er den levende kontrakt — kommende ændringer vises separat.</div></div><div class="d-flex gap-4"><div class="agreement-metric"><div class="agreement-metric-label">Aktuelle</div><div class="h4 mb-0 fw-bold">${subscriptions.length}</div></div><div class="agreement-metric"><div class="agreement-metric-label">Samlet værdi</div><div class="h4 mb-0 fw-bold">${formatSubscriptionCurrency(totalValue)}</div></div><div class="agreement-metric"><div class="agreement-metric-label">Åbne ændringer</div><div class="h4 mb-0 fw-bold">${openChanges.length}</div></div></div></div></div>
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 align-items-md-center mb-3">
<div><h6 class="mb-0 fw-bold text-dark">Aktuelle og kommende abonnementer</h6><div class="small text-muted">Markér ét eller flere kort for at samle dem i én ændringsundersag.</div></div>
<div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary rounded-pill px-3" onclick="showSubscriptionCreateForm()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
${payload.allowed_actions?.request_change ? '<button class="btn btn-sm btn-primary rounded-pill px-3" onclick="openAgreementChangeComposer()"><i class="bi bi-stars me-1"></i>Opret ændringspakke</button>' : ''}</div>
<div><h6 class="mb-0 fw-bold">Aktuelle og kommende abonnementer</h6><div class="small text-muted">Klik Rediger på abonnementet, ret felterne og gem ændringen.</div></div>
<div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" onclick="showSubscriptionCreateForm()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
${changePanel}
<div class="agreement-toolbar mb-3"><div class="row g-2 align-items-center"><div class="col-lg-6"><div class="input-group"><span class="input-group-text border-0 bg-transparent"><i class="bi bi-search text-muted"></i></span><input class="form-control agreement-search" id="agreementSubscriptionSearch" placeholder="Søg produkt, nummer, asset, serienummer eller note…"></div></div><div class="col-lg-6"><div class="d-flex flex-wrap gap-1 justify-content-lg-end"><button class="agreement-filter active" data-filter="all">Alle</button><button class="agreement-filter" data-filter="current">Aktuelle</button><button class="agreement-filter" data-filter="upcoming">Kommende</button><button class="agreement-filter" data-filter="blocked">Blokerede</button></div></div></div></div>
<div class="card border-primary-subtle bg-primary-subtle mb-3 d-none" id="agreementChangeComposer"><div class="card-body"><div class="d-flex justify-content-between align-items-start mb-3"><div><h6 class="fw-bold mb-1"><i class="bi bi-stars me-2"></i>Klargør ændringspakke</h6><div class="small text-muted" id="agreementSelectionSummary">Vælg abonnementer i kortene nedenfor.</div></div><button class="btn-close" onclick="document.getElementById('agreementChangeComposer').classList.add('d-none')"></button></div><div class="row g-3"><div class="col-md-7"><label class="form-label">Begrundelse *</label><textarea class="form-control" id="agreementChangeReason" rows="2" placeholder="Hvad ændres — og hvorfor?"></textarea></div><div class="col-md-3"><label class="form-label">Ikrafttrædelse *</label><input type="date" class="form-control" id="agreementEffectiveDate"></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100" onclick="saveSelectedSubscriptionChange()"><i class="bi bi-arrow-right-circle me-1"></i>Fortsæt</button></div></div></div></div>
<div class="card border-primary-subtle bg-primary-subtle mb-3 d-none" id="agreementChangeComposer"><div class="card-body"><div class="d-flex justify-content-between align-items-start mb-3"><div><h6 class="fw-bold mb-1"><i class="bi bi-stars me-2"></i>Gem abonnementsændring</h6><div class="small text-muted" id="agreementSelectionSummary">Ret oplysningerne nedenfor, og gem ændringen.</div></div><button class="btn-close" onclick="cancelSimpleSubscriptionChange()"></button></div><div class="row g-3"><div class="col-md-7"><label class="form-label">Note (valgfri)</label><textarea class="form-control" id="agreementChangeReason" rows="1" placeholder="Evt. en kort bemærkning"></textarea></div><div class="col-md-3"><label class="form-label">Ikrafttrædelse *</label><input type="date" class="form-control" id="agreementEffectiveDate"></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100" id="agreementSaveChangeBtn" onclick="saveSelectedSubscriptionChange()"><i class="bi bi-check-lg me-1"></i>Gem ændring</button></div></div></div></div>
<datalist id="subscriptionProductOptions">${subscriptionProducts.map(product => `<option value="${escapeHtml(product.name || '')}">${escapeHtml([product.sku_internal, product.short_description].filter(Boolean).join(' · '))}</option>`).join('')}</datalist>
<div class="row g-3">${subscriptions.map(subscription => `
<div class="col-12 agreement-card-wrap" data-state="${subscription.status === 'scheduled' || (subscription.start_date && new Date(subscription.start_date) > new Date()) ? 'upcoming' : 'current'}" data-blocked="${subscription.billing_blocked || subscription.status === 'blocked'}" data-search="${escapeHtml(agreementSearchText(subscription))}"><div class="card agreement-card subscription-agreement-card" data-subscription-id="${subscription.id}"><div class="agreement-card-accent"></div>
<div class="card-header bg-white border-0 pt-3 d-flex justify-content-between align-items-start">
<label class="d-flex align-items-center gap-3 mb-0"><input class="form-check-input subscription-change-select mt-0" type="checkbox" value="${subscription.id}">
<div class="card-header pt-3 d-flex justify-content-between align-items-start">
<label class="d-flex align-items-center gap-3 mb-0"><input class="form-check-input subscription-change-select mt-0 d-none" type="checkbox" value="${subscription.id}">
<span><span class="agreement-number d-block">${subscription.subscription_number || '#' + subscription.id}</span><span class="agreement-name">${escapeHtml(subscription.product_name || 'Abonnement')}</span></span></label>
<div class="text-end"><span class="badge rounded-pill ${subscription.status === 'active' ? 'bg-success-subtle text-success' : subscription.status === 'blocked' ? 'bg-danger-subtle text-danger' : 'bg-secondary-subtle text-secondary'}">${subscriptionLifecycleLabel(subscription.status)}</span><div class="agreement-price mt-2">${formatSubscriptionCurrency(subscription.price)}</div>${subscription.status === 'draft' ? `<div class="d-flex justify-content-end gap-2 mt-2"><button type="button" class="btn btn-sm btn-outline-primary" onclick="editDraftFromAgreement(${subscription.id})"><i class="bi bi-pencil me-1"></i>Rediger kladde</button><button type="button" class="btn btn-sm btn-success" onclick="activateDraftFromAgreement(${subscription.id})"><i class="bi bi-play-fill me-1"></i>Aktivér</button></div>` : subscription.status === 'active' ? `<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="processSubscriptionFromAgreement(${subscription.id})"><i class="bi bi-receipt-cutoff me-1"></i>Fakturér nu</button>` : ''}</div>
<div class="text-end"><span class="badge ${subscription.status === 'active' ? 'bg-success-subtle text-success' : subscription.status === 'blocked' ? 'bg-danger-subtle text-danger' : 'bg-secondary-subtle text-secondary'}">${subscriptionLifecycleLabel(subscription.status)}</span><div class="agreement-price mt-2">${formatSubscriptionCurrency(subscription.price)}</div>${subscription.status === 'draft' ? `<div class="d-flex justify-content-end gap-2 mt-2"><button type="button" class="btn btn-sm btn-outline-primary" onclick="editDraftFromAgreement(${subscription.id})"><i class="bi bi-pencil me-1"></i>Rediger kladde</button><button type="button" class="btn btn-sm btn-success" onclick="activateDraftFromAgreement(${subscription.id})"><i class="bi bi-play-fill me-1"></i>Aktivér</button></div>` : `<div class="d-flex justify-content-end gap-2 mt-2">${payload.allowed_actions?.request_change ? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="startSimpleSubscriptionChange(${subscription.id})"><i class="bi bi-pencil me-1"></i>Rediger</button>` : ''}${subscription.status === 'active' ? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="processSubscriptionFromAgreement(${subscription.id})"><i class="bi bi-receipt-cutoff me-1"></i>Fakturér nu</button>` : ''}</div>`}</div>
</div>
<div class="card-body">
<div class="row g-2">
@ -22597,7 +22690,7 @@
if (!cards.length) return showCaseFeedback('Vælg mindst ét abonnement');
const reason = document.getElementById('agreementChangeReason')?.value.trim();
const effectiveDate = document.getElementById('agreementEffectiveDate')?.value;
if (!reason || !effectiveDate) return showCaseFeedback('Angiv begrundelse og ikrafttrædelsesdato');
if (!effectiveDate) return showCaseFeedback('Vælg hvornår ændringen skal gælde');
const proposals = cards.map(card => ({
subscription_id: Number(card.dataset.subscriptionId),
changes: {
@ -22611,10 +22704,49 @@
}));
const invalidProposal = proposals.find(proposal => !proposal.changes.line_items.length || proposal.changes.line_items.some(line => !line.description || line.quantity <= 0 || line.unit_price < 0));
if (invalidProposal) return showCaseFeedback('Alle valgte abonnementer skal have mindst én gyldig varelinje med beskrivelse, antal og pris.');
const res = await fetch('/api/v1/sag-subscriptions/change-requests', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({main_sag_id: subscriptionCaseId, reason, effective_date: effectiveDate, proposals})});
const result = await res.json();
if (!res.ok) return showCaseFeedback(result.detail || 'Kunne ikke oprette ændringsundersag');
window.location.href = result.case_url;
const saveButton = document.getElementById('agreementSaveChangeBtn');
if (saveButton?.disabled) return;
if (saveButton) saveButton.disabled = true;
let saved = false;
try {
const result = await intakeSubscriptionChangeRequest('/api/v1/sag-subscriptions/change-requests', {main_sag_id:subscriptionCaseId,reason:reason || 'Ændring af abonnement',effective_date:effectiveDate,proposals});
saved = true;
const savedChange = await intakeSubscriptionChangeRequest(`/api/v1/sag-subscriptions/change-requests/${result.id}/save`);
if (['failed','partially_applied'].includes(savedChange.status)) throw new Error('Ændringen kunne ikke gennemføres. Se ændringshistorikken.');
await loadSubscriptionForCase();
showCaseFeedback(savedChange.status === 'approved_scheduled' ? 'Ændringen er gemt og planlagt til den valgte dato.' : 'Ændringen er gemt og gennemført.', 'success');
} catch (error) {
showCaseFeedback(saved ? `Ændringsforslaget er gemt, men kunne ikke gennemføres: ${error.message}. Prøv Gem ændring igen.` : error.message);
} finally {
if (saveButton) saveButton.disabled = false;
}
}
async function intakeSubscriptionChangeRequest(url, body) {
const response = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'}, ...(body ? {body:JSON.stringify(body)} : {})});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Ændringen kunne ikke gemmes');
return result;
}
function startSimpleSubscriptionChange(subscriptionId) {
const subscription = agreementSubscriptionById(subscriptionId);
if (!subscription) return showCaseFeedback('Kunne ikke finde abonnementet');
document.querySelectorAll('.subscription-change-select').forEach(input => input.checked = Number(input.value) === Number(subscriptionId));
agreementChangeMode = true;
syncAgreementChangeMode();
const card = document.querySelector(`.subscription-agreement-card[data-subscription-id="${subscriptionId}"]`);
const composer = document.getElementById('agreementChangeComposer');
card?.querySelector('.card-body')?.prepend(composer);
composer?.classList.remove('d-none');
document.getElementById('agreementSelectionSummary').textContent = 'Ret felterne på dette abonnement. Gem anvender ændringen fra den valgte dato.';
document.getElementById('agreementEffectiveDate').value = new Date().toLocaleDateString('sv-SE',{timeZone:'Europe/Copenhagen'});
refreshAgreementChangePreview(card);
card?.scrollIntoView({behavior:'smooth',block:'start'});
}
function cancelSimpleSubscriptionChange() {
loadSubscriptionForCase();
}
function openAgreementChangeComposer() {
@ -22638,17 +22770,17 @@
const overview = document.getElementById('subscriptionAgreementOverview');
overview.classList.remove('d-none');
const change = payload.change_request;
overview.innerHTML = `<div class="change-case-hero mb-3"><div class="d-flex flex-column flex-md-row justify-content-between gap-3"><div><div class="agreement-hero-kicker text-primary">Godkendelsespakke #${change.id}</div><h5 class="fw-bold mb-1">${escapeHtml(change.reason || 'Abonnementsændring')}</h5><div class="text-muted small">Oprettet af ${escapeHtml(change.created_by_name || 'ukendt')} · ikraft ${formatSubscriptionDate(change.effective_date)}</div></div><div><span class="badge rounded-pill bg-info-subtle text-info-emphasis px-3 py-2">${changeStatusLabel(change.status)}</span></div></div></div>
${(payload.items || []).map(item => `<div class="card mb-3"><div class="card-header bg-white d-flex justify-content-between align-items-center py-3"><div><span class="agreement-number d-block">${item.subscription_number}</span><strong>${escapeHtml(item.product_name || '')}</strong></div><span class="badge rounded-pill ${item.apply_status === 'failed' ? 'bg-danger-subtle text-danger' : item.apply_status === 'applied' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'}">${item.apply_status}</span></div><div class="card-body">${renderSubscriptionDiff(item.before_snapshot, item.proposed_snapshot)}${item.error_message ? `<div class="alert alert-danger mt-3 mb-0"><i class="bi bi-exclamation-octagon me-1"></i>${escapeHtml(item.error_message)}</div>` : ''}</div></div>`).join('')}
overview.innerHTML = `<div class="change-case-hero mb-3"><div class="d-flex flex-column flex-md-row justify-content-between gap-3"><div><div class="agreement-hero-kicker text-primary">Abonnementsændring #${change.id}</div><h5 class="fw-bold mb-1">${escapeHtml(change.reason || 'Abonnementsændring')}</h5><div class="text-muted small">Oprettet af ${escapeHtml(change.created_by_name || 'ukendt')} · ikraft ${formatSubscriptionDate(change.effective_date)}</div></div><div><span class="badge bg-info-subtle text-info-emphasis px-3 py-2">${changeStatusLabel(change.status)}</span></div></div></div>
${(payload.items || []).map(item => `<div class="card mb-3"><div class="card-header bg-white d-flex justify-content-between align-items-center py-3"><div><span class="agreement-number d-block">${item.subscription_number}</span><strong>${escapeHtml(item.product_name || '')}</strong></div><span class="badge ${item.apply_status === 'failed' ? 'bg-danger-subtle text-danger' : item.apply_status === 'applied' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'}">${item.apply_status}</span></div><div class="card-body">${renderSubscriptionDiff(item.before_snapshot, item.proposed_snapshot)}${item.error_message ? `<div class="alert alert-danger mt-3 mb-0"><i class="bi bi-exclamation-octagon me-1"></i>${escapeHtml(item.error_message)}</div>` : ''}</div></div>`).join('')}
<div class="d-flex flex-wrap gap-2 sticky-bottom bg-white border-top py-3"><a class="btn btn-outline-secondary" href="/sag/${change.main_sag_id}/v3?tab=subscription"><i class="bi bi-arrow-left me-1"></i>Til hovedsag</a>
${change.status === 'draft' && payload.allowed_actions.request_change ? `<button class="btn btn-primary" onclick="subscriptionChangeAction(${change.id}, 'submit')">Indsend</button>` : ''}
${payload.allowed_actions.approve_this ? `<button class="btn btn-success" onclick="subscriptionChangeAction(${change.id}, 'approve')"><i class="bi bi-shield-check me-1"></i>Godkend og anvend</button><button class="btn btn-outline-danger" onclick="subscriptionChangeAction(${change.id}, 'reject')">Afvis</button>` : ''}
${['draft','pending'].includes(change.status) && payload.allowed_actions.request_change ? `<button class="btn btn-primary" onclick="subscriptionChangeAction(${change.id}, 'save')">Gem ændring</button>` : ''}
${['failed','partially_applied'].includes(change.status) && payload.allowed_actions.approve ? `<button class="btn btn-warning" onclick="subscriptionChangeAction(${change.id}, 'retry')">Genforsøg fejlede</button>` : ''}</div>`;
setSubscriptionBadge(change.status);
}
function changeStatusLabel(status) {
return {draft:'Kladde',pending:'Afventer godkendelse',approved_scheduled:'Godkendt · planlagt',applying:'Anvendes nu',partially_applied:'Delvist gennemført',applied:'Gennemført',rejected:'Afvist',failed:'Kræver handling',cancellation_pending:'Annullering afventer',cancelled:'Annulleret'}[status] || status;
return {draft:'Kladde',pending:'Afventer gemning',approved_scheduled:'Planlagt',applying:'Anvendes nu',partially_applied:'Delvist gennemført',applied:'Gennemført',rejected:'Afvist',failed:'Kræver handling',cancellation_pending:'Annullering afventer',cancelled:'Annulleret'}[status] || status;
}
function renderSubscriptionDiff(before, after) {

View File

@ -2,7 +2,7 @@
{% block title %}{{ case.titel }} · Sagsbord V5 - BMC Hub{% endblock %}
{% block extra_css %}
{{ super() }}
<link rel="stylesheet" href="/static/css/case-v5.css?v=20261014-call-list">
<link rel="stylesheet" href="/static/css/case-v5.css?v=20261020-time-grid">
<link rel="stylesheet" href="/static/css/case-command-composer.css?v=20261006-4">
{% endblock %}
{% block content %}
@ -38,7 +38,9 @@
'groupId': case.assigned_group_id,
'userId': case.ansvarlig_bruger_id
}|tojson }}</script>
<script src="/static/js/case-v5.js?v=20261014-call-list"></script>
<script src="/static/js/case-v5.js?v=20261020-simple-planner-2"></script>
<script src="/static/js/case-week-calendar.js?v=20261020-1"></script>
<script src="/static/js/case-slot-resize.js?v=20261020-2"></script>
<script src="/static/js/case-commands.js?v=20261006-1"></script>
<script src="/static/js/case-command-suggestions.js?v=20261014-initials"></script>
<script src="/static/js/case-command-composer.js?v=20261014-first-suggestion-enter"></script>

View File

@ -35,7 +35,7 @@ def proposals(draft):
rules = query('SELECT * FROM economic_price_rules WHERE active')
result = []
for index, line in enumerate(lines):
if not line.get('product_id') or line.get('source_type') in ('subscription', 'invoice_fee'):
if not line.get('product_id') or line.get('source_type') in ('subscription', 'invoice_fee', 'also_cloud'):
# The existing subscription price is an agreed contract price.
continue
p = query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (line['product_id'],), one=True)

View File

@ -20,7 +20,8 @@ from app.models.schemas import (
AnyDeskSessionWithWorklog
)
from app.services.anydesk import AnyDeskService
from app.core.database import execute_query
from app.core.database import execute_query, atomic_transaction
from app.services.anydesk_links import remember_machine, apply_machine_links
logger = logging.getLogger(__name__)
@ -1481,6 +1482,8 @@ async def link_session(
customer_id: Optional[int] = None,
hardware_asset_id: Optional[int] = None,
notes: Optional[str] = None,
remember_machine_link: bool = False,
require_same_customer: bool = False,
):
"""
Manually link a session to sag, contact, customer, hardware, or add notes.
@ -1503,12 +1506,28 @@ async def link_session(
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})
with atomic_transaction():
if sag_id is not None and require_same_customer:
case = _ensure_case_exists(sag_id)
session = execute_query('SELECT customer_id FROM anydesk_sessions WHERE id=%s FOR UPDATE', (session_id,))
if not session:
raise HTTPException(404, 'Sessionen findes ikke')
if not session[0]['customer_id'] or case['customer_id'] != session[0]['customer_id']:
raise HTTPException(409, 'Sagen skal tilhøre samme firma som sessionen')
machine_id = None
if remember_machine_link:
machine_id = remember_machine(session_id, customer_id, contact_id, hardware_asset_id)
if contact_id is None:
sets.append('contact_id = NULL')
execute_query(
f"UPDATE anydesk_sessions SET {', '.join(sets)} WHERE id = %s",
tuple(params)
)
linked = apply_machine_links(machine_id) if machine_id else 0
return JSONResponse(content={"ok": True, "remembered": bool(machine_id), "linked_sessions": linked})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error linking session {session_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -17,6 +17,7 @@ import aiohttp
from app.core.config import settings
from app.core.database import execute_query
from app.services.anydesk_links import apply_machine_links
logger = logging.getLogger(__name__)
@ -571,6 +572,7 @@ class AnyDeskService:
errors.append(str(exc))
logger.warning(f"⚠️ Could not import entry: {exc}")
apply_machine_links()
logger.info(f"✅ AnyDesk import done: {imported} new, {updated} updated, {len(errors)} errors")
return {
"imported": imported,
@ -793,6 +795,8 @@ class AnyDeskService:
logger.warning("⚠️ Failed local AnyDesk entry sync: %s", exc)
errors.append(str(exc))
if not dry_run:
apply_machine_links()
logger.info(
"✅ Local AnyDesk sync done: %d imported, %d updated (%d matched), %d errors",
imported,

View File

@ -0,0 +1,145 @@
"""Remember the customer end of an AnyDesk session, never the technician end."""
import json
import re
from fastapi import HTTPException
from app.core.database import execute_query
def customer_machine_id(device_info):
if isinstance(device_info, str):
device_info = json.loads(device_info)
value = re.sub(r'[\s-]', '', str((device_info or {}).get('to_id') or ''))
return value if re.fullmatch(r'[0-9]{1,64}', value) else None
def remember_machine(session_id, customer_id, contact_id, hardware_asset_id=None):
"""Caller owns the transaction including the selected session's update."""
rows = execute_query('SELECT device_info,hardware_asset_id FROM anydesk_sessions WHERE id=%s', (session_id,))
if not rows:
raise HTTPException(404, 'Sessionen findes ikke')
machine_id = customer_machine_id(rows[0]['device_info'])
if not machine_id:
raise HTTPException(409, 'Sessionen mangler et entydigt AnyDesk-ID for kundens maskine')
customer = execute_query('SELECT id FROM customers WHERE id=%s AND is_active=TRUE', (customer_id,))
if not customer:
raise HTTPException(409, 'Vælg et aktivt firma for at gemme AnyDesk-koblingen')
if contact_id is not None:
contact = execute_query('''SELECT c.id FROM contacts c
JOIN contact_companies cc ON cc.contact_id=c.id
WHERE c.id=%s AND cc.customer_id=%s AND c.is_active=TRUE''', (contact_id, customer_id))
if not contact:
raise HTTPException(409, 'Kontaktpersonen skal være aktiv og tilknyttet det valgte firma')
hardware_id = _remember_hardware(machine_id, customer_id, contact_id,
hardware_asset_id or rows[0].get('hardware_asset_id'))
execute_query('UPDATE anydesk_sessions SET hardware_asset_id=%s WHERE id=%s', (hardware_id, session_id))
return machine_id
def _remember_hardware(machine_id, customer_id, contact_id, selected_id):
# Serialize registrations of this ID so repeated/concurrent saves reuse one PC.
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('anydesk-hardware:' + machine_id,))
matches = execute_query('''SELECT id,anydesk_id,current_owner_customer_id,current_owner_type
FROM hardware_assets WHERE deleted_at IS NULL
AND regexp_replace(COALESCE(anydesk_id,''),'[[:space:]-]','','g')=%s FOR UPDATE''', (machine_id,))
if len(matches) > 1:
raise HTTPException(409, 'AnyDesk-ID findes på flere hardwareposter. Ret dubletterne før registrering.')
if selected_id is not None:
selected = execute_query('''SELECT id,anydesk_id,current_owner_customer_id,current_owner_type
FROM hardware_assets WHERE id=%s AND deleted_at IS NULL FOR UPDATE''', (selected_id,))
if not selected:
raise HTTPException(409, 'Den valgte hardware findes ikke')
if matches and matches[0]['id'] != selected_id:
raise HTTPException(409, 'AnyDesk-ID findes allerede på en anden hardwarepost')
if selected[0].get('anydesk_id') and customer_machine_id({'to_id': selected[0]['anydesk_id']}) != machine_id:
raise HTTPException(409, 'Den valgte hardware har et andet AnyDesk-ID')
matches = selected
elif not matches and contact_id is not None:
matches = execute_query('''SELECT h.id,h.anydesk_id,h.current_owner_customer_id,h.current_owner_type
FROM hardware_assets h JOIN hardware_contacts hc ON hc.hardware_id=h.id
WHERE hc.contact_id=%s AND h.current_owner_customer_id=%s AND h.deleted_at IS NULL
AND h.status='active' AND h.asset_type IN ('pc','laptop')
AND NULLIF(BTRIM(h.anydesk_id),'') IS NULL FOR UPDATE OF h''', (contact_id, customer_id))
if len(matches) > 1:
raise HTTPException(409, 'Kontaktpersonen har flere computere uden AnyDesk-ID. Vælg den rigtige hardware først.')
if matches:
hardware = matches[0]
if hardware['current_owner_customer_id'] not in (None, customer_id):
raise HTTPException(409, 'Hardwaren tilhører et andet firma. Ret ejerskabet på hardwarekortet først.')
if hardware['current_owner_customer_id'] is None and hardware.get('current_owner_type') not in (None, 'customer'):
raise HTTPException(409, 'Hardwarens ejerskab skal afklares på hardwarekortet først.')
if contact_id is not None:
other_contacts = execute_query('''SELECT hc.contact_id FROM hardware_contacts hc
JOIN contacts c ON c.id=hc.contact_id AND c.is_active=TRUE
WHERE hc.hardware_id=%s AND hc.contact_id<>%s''', (hardware['id'], contact_id))
if other_contacts:
raise HTTPException(409, 'Hardwaren er koblet til en anden kontaktperson. Ret koblingen på hardwarekortet først.')
hardware_id = hardware['id']
execute_query('''UPDATE hardware_assets SET anydesk_id=%s,anydesk_link=%s,
current_owner_type=COALESCE(current_owner_type,'customer'),current_owner_customer_id=%s,updated_at=NOW() WHERE id=%s''',
(machine_id, 'anydesk:' + machine_id, customer_id, hardware_id))
else:
hardware_id = execute_query('''INSERT INTO hardware_assets
(asset_type,model,current_owner_type,current_owner_customer_id,status,anydesk_id,anydesk_link,notes)
VALUES('pc','PC','customer',%s,'active',%s,%s,'Oprettet fra AnyDesk-session') RETURNING id''',
(customer_id, machine_id, 'anydesk:' + machine_id))[0]['id']
if not matches or matches[0]['current_owner_customer_id'] is None:
execute_query('''INSERT INTO hardware_ownership_history
(hardware_id,owner_type,owner_customer_id,start_date,notes)
VALUES(%s,'customer',%s,CURRENT_DATE,'Registreret fra AnyDesk-session')''', (hardware_id, customer_id))
if contact_id is not None:
execute_query('''INSERT INTO hardware_contacts(hardware_id,contact_id,role,source)
VALUES(%s,%s,'primary','anydesk') ON CONFLICT(hardware_id,contact_id) DO NOTHING''',
(hardware_id, contact_id))
return hardware_id
def apply_machine_links(machine_id=None):
"""Hardware is authoritative. Ambiguous machine IDs are never auto-linked."""
rows = execute_query('''UPDATE anydesk_sessions s SET hardware_asset_id=COALESCE(s.hardware_asset_id,m.id),
customer_id=COALESCE(s.customer_id,m.customer_id),contact_id=COALESCE(s.contact_id,m.contact_id),updated_at=NOW()
FROM (WITH hardware AS (
SELECT regexp_replace(anydesk_id,'[[:space:]-]','','g') AS machine,
MIN(id) AS id
FROM hardware_assets WHERE deleted_at IS NULL AND anydesk_id IS NOT NULL
GROUP BY regexp_replace(anydesk_id,'[[:space:]-]','','g') HAVING COUNT(*)=1
), links AS (
SELECT h.id,h.current_owner_customer_id AS customer_id,hw.machine,
CASE WHEN COUNT(DISTINCT con.id)=1 THEN MIN(con.id) ELSE NULL END AS contact_id
FROM hardware hw JOIN hardware_assets h ON h.id=hw.id
JOIN customers c ON c.id=h.current_owner_customer_id AND c.is_active=TRUE
LEFT JOIN hardware_contacts hc ON hc.hardware_id=h.id
LEFT JOIN contacts con ON con.id=hc.contact_id AND con.is_active=TRUE AND EXISTS (
SELECT 1 FROM contact_companies cc WHERE cc.contact_id=con.id AND cc.customer_id=c.id)
WHERE hw.machine ~ '^[0-9]+$' AND (%s IS NULL OR hw.machine=%s)
GROUP BY h.id,h.current_owner_customer_id,hw.machine
) SELECT * FROM links) m
WHERE regexp_replace(COALESCE(s.device_info->>'to_id',''),'[[:space:]-]','','g')=m.machine
AND (s.hardware_asset_id IS NULL OR s.hardware_asset_id=m.id)
AND ((s.customer_id IS NULL AND s.contact_id IS NULL) OR s.customer_id=m.customer_id)
AND (s.hardware_asset_id IS NULL OR s.customer_id IS NULL OR (s.contact_id IS NULL AND m.contact_id IS NOT NULL))
RETURNING s.id''', (machine_id, machine_id))
return len(rows or []) + _apply_legacy_links(machine_id)
def _apply_legacy_links(machine_id=None):
"""Fill only unassigned sessions or a missing contact for the same customer."""
table = execute_query("SELECT to_regclass('anydesk_machine_links') AS present")
if not table or not table[0]['present']:
return 0
rows = execute_query('''UPDATE anydesk_sessions s SET
customer_id=COALESCE(s.customer_id,m.customer_id),
contact_id=COALESCE(s.contact_id,m.contact_id),updated_at=NOW()
FROM anydesk_machine_links m JOIN customers c ON c.id=m.customer_id AND c.is_active=TRUE
LEFT JOIN contacts con ON con.id=m.contact_id
WHERE regexp_replace(COALESCE(s.device_info->>'to_id',''), '[[:space:]-]', '', 'g')=m.anydesk_id
AND (%s IS NULL OR m.anydesk_id=%s)
AND NOT EXISTS (SELECT 1 FROM hardware_assets h WHERE h.deleted_at IS NULL
AND regexp_replace(COALESCE(h.anydesk_id,''),'[[:space:]-]','','g')=m.anydesk_id)
AND (m.contact_id IS NULL OR (con.is_active=TRUE AND EXISTS (
SELECT 1 FROM contact_companies cc WHERE cc.contact_id=m.contact_id AND cc.customer_id=m.customer_id)))
AND ((s.customer_id IS NULL AND s.contact_id IS NULL)
OR (s.customer_id=m.customer_id AND s.contact_id IS NULL AND m.contact_id IS NOT NULL))
RETURNING s.id''', (machine_id, machine_id))
return len(rows or [])

View File

@ -98,6 +98,12 @@ class ReminderNotificationService:
'rate_limited_users': [],
'logged_id': None
}
from app.core.case_visibility import notification_recipients
recipient_user_ids, confidential = notification_recipients(sag_id,recipient_user_ids)
if confidential:
# Shared chat channels and arbitrary email addresses have no case ACL.
notify_mattermost = False
recipient_emails = []
self._refresh_mattermost_settings()
if self.dry_run:
@ -131,6 +137,8 @@ class ReminderNotificationService:
notify_frontend,
override_user_preferences
)
if confidential:
channels = [channel for channel in channels if channel != 'mattermost']
# Get user email
user_query = "SELECT email, username FROM users WHERE user_id = %s"

View File

@ -1495,11 +1495,10 @@
</li>
<li class="nav-item dropdown" data-menu-key="menu-kalender">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-calendar3 me-2"></i>Kalender
<i class="bi bi-calendar3 me-2"></i>Planlægning
</a>
<ul class="dropdown-menu mt-2">
<li data-menu-key="menu-kalender-overview"><a class="dropdown-item py-2" href="/calendar"><i class="bi bi-calendar3 me-2"></i>Kalenderoversigt</a></li>
<li data-menu-key="menu-planner"><a class="dropdown-item py-2" href="/planner"><i class="bi bi-calendar2-week me-2"></i>Planlægger</a></li>
<li data-menu-key="menu-planner"><a class="dropdown-item py-2" href="/planning"><i class="bi bi-calendar2-week me-2"></i>Planlægning</a></li>
</ul>
</li>
<li class="nav-item dropdown" data-menu-key="menu-support">
@ -1868,6 +1867,9 @@
</button>
</div>
<div class="bb-zone bb-zone-right">
<a class="bb-action-btn" href="/planning?next=1" title="Næste opgave"><i class="bi bi-skip-end"></i><span>Næste opgave</span></a>
<a class="bb-action-btn" href="/planning?view=queue" title="Min kø"><i class="bi bi-list-task"></i><span>Min kø</span></a>
<a class="bb-action-btn" href="/planning?view=calendar" title="Kalender"><i class="bi bi-calendar3"></i></a>
<button id="bbActiveTimerChip" class="bb-activity-chip is-hidden" type="button" title="Aktiv timer">
<i class="bi bi-stopwatch"></i>
<span id="bbActiveTimerText">Ingen aktiv timer</span>
@ -3732,6 +3734,8 @@ if (bmcOriginalFetch) {
{% block scripts %}{% endblock %}
<script src="/static/js/quick-time-shortcut.js?v=20261007-1"></script>
<script src="/static/js/case-privacy.js?v=20261017-4"></script>
<script src="/static/js/case-planning-summary.js?v=20261017-4"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -579,7 +579,7 @@ def _create_change_case(cursor, main_case: Dict[str, Any], user_id: int, reason:
""",
(f"Abonnementsændring · {main_case.get('titel') or '#' + str(main_case['id'])}", reason or "", main_case.get("customer_id"), user_id),
)
change_sag_id = int(cursor.fetchone()[0])
change_sag_id = int(cursor.fetchone()["id"])
cursor.execute(
"""
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
@ -857,6 +857,42 @@ def _sync_main_case_lifecycle(main_sag_id: int) -> None:
)
@router.post('/sag-subscriptions/change-requests/{change_id}/save', response_model=Dict[str, Any])
async def save_subscription_change_directly(
change_id: int,
current_user: Dict[str, Any] = Depends(require_permission('subscriptions.change_request')),
):
"""Save an editable change without a separate approval, preserving its audit trail."""
actor_id = int(current_user['id'])
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('SELECT * FROM subscription_change_requests WHERE id=%s FOR UPDATE', (change_id,))
change = cursor.fetchone()
if not change:
raise HTTPException(404, 'Ændringen findes ikke')
if change['status'] in {'applied', 'approved_scheduled', 'applying'}:
conn.rollback()
return {'id': change_id, 'status': change['status']}
if change['status'] not in {'draft', 'pending'}:
raise HTTPException(409, 'Denne ændring kan ikke gemmes i sin nuværende status')
cursor.execute("""UPDATE subscription_change_requests
SET status='approved_scheduled', approved_by_user_id=NULL, approved_at=NULL,
updated_at=CURRENT_TIMESTAMP WHERE id=%s""", (change_id,))
cursor.execute("""INSERT INTO subscription_events
(main_sag_id,change_request_id,event_type,actor_user_id)
VALUES(%s,%s,'change_saved_directly',%s)""", (change['main_sag_id'],change_id,actor_id))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
if _safe_date(change['effective_date']) <= date.today():
return apply_subscription_change_request(change_id, actor_id)
return {'id': change_id, 'status': 'approved_scheduled', 'effective_date': change['effective_date']}
@router.post("/sag-subscriptions/change-requests/{change_id}/approve", response_model=Dict[str, Any])
async def approve_subscription_change_request(
change_id: int,
@ -971,7 +1007,7 @@ async def approve_scheduled_change_cancellation(
def apply_due_subscription_changes() -> int:
changes = execute_query(
"""SELECT id, approved_by_user_id FROM subscription_change_requests
"""SELECT id, COALESCE(approved_by_user_id, created_by_user_id) AS approved_by_user_id FROM subscription_change_requests
WHERE status = 'approved_scheduled' AND effective_date <= CURRENT_DATE ORDER BY id"""
) or []
for change in changes:

107
docs/UNIFIED_PLANNING.md Normal file
View File

@ -0,0 +1,107 @@
# Samlet planlægning
Lokal implementering, 17. oktober 2026. Produktionsudrulning og endelig brugeraccept er ikke gennemført.
## Arbejdsflade og datakilder
`/planning` har tre hovedfaner: **Mit arbejde** (kø og kalender med dag/uge/måned), **Opgaver** (sager uden medarbejder, medarbejderkø, tildeling og datoer) og **Team og kapacitet** (medarbejderoversigt og kapacitet). De tidligere view-parametre fungerer fortsat som indgange til de samlede faner. `/planner`, eksisterende kalenderadresser og kalenderabonnementer bevares. Medarbejderoversigten genbruger den eksisterende planlægger; kalenderen bruger FullCalendar. Bundmenuen åbner næste opgave, personlig kø og kalender. Næste opgave starter aldrig timeren.
- `sag_case_assignees` er den entydige allokering pr. sag/medarbejder. Individuelt estimat, manuel rest, status, udsættelse, fastlåsning og position ligger på relationen. Ansvarlig er fortsat en selvstændig rolle.
- Køen sorteres efter fastlåsning og gemt position. Første overgang bruger prioritet, deadline og sags-ID; senere ændringer af deadline/SLA flytter ikke køen. Positioner har numeriske mellemrum og omnummereres kun ved behov. Køversion beskytter også imod samtidige medlemsændringer fra gamle tildelingshandlinger.
- `planner_events` holder fælles tidspunkt/titel; `planner_allocations` bevarer booking-ID og medarbejderreservation. Gamle bookinger får hver sin post. Der foretages ingen sammenlægning efter tidslighed. Den gamle API afviser individuel flytning af en fælles aftale; fællesændringen foretages gennem event-API'en.
- `quick_time_items` læses direkte. Planlagt arbejde kopieres ikke. Konverteret arbejde vises gennem sin kanoniske tidsregistrering.
- Planlagt fravær ligger i `planner_absences`. Kun den udtrykkelige handling "Registrer som afholdt" opretter faktisk tid i `tmodule_times`. Forbindelsen er entydig pr. fravær og arbejdsdato. Gentagelse opretter ikke mere tid. Fravær er ikke fakturerbart og kan ikke annulleres som planlagt efter konvertering.
- Kapacitet bruger medarbejderens arbejdstider og unionen af bookinger, fravær, eksternt optaget og selvstændige planer. Faktisk tid er information, ikke endnu en reservation. Overlap markeres samtidig som konflikt. Køprognosen trækker fremtidigt booket personarbejde fra individuel rest; ukendte estimater forbliver ukendte.
- Hoved-/undersagsoversigten bruger unikke sags-ID'er, også ved flere relationer eller cykler, og summerer egne registreringer pr. sag. Egne, undersagers og samlede værdier kan vælges separat.
I Opgaver kan sager trækkes til en medarbejder, mellem køplaceringer og til en kalenderdato. Fra en eksisterende medarbejder overdrages allokeringen; fra indbakken oprettes den. Et særskilt dropfelt fjerner den valgte medarbejders allokering og bevarer øvrige medarbejdere og ansvarlig. Ved overdragelse med kommende reservationer spørges der, om tiderne skal overdrages til den nye medarbejder eller fjernes. Alle tidsrum håndteres samlet med versionskontrol, konfliktbekræftelse og audit i samme transaktion. Andre mødedeltagere og registreret tid bevares. Fjernelse af en allokering uden overdragelse kræver fortsat, at kommende reservationer først håndteres. Start-/deadlinevalg styrer, hvilken dato en opgave ændrer. Eksisterende start- og deadlinemarkeringer kan også flyttes. Knapper tilbyder samme handlinger på mobil. Yderligere køhandlinger er samlet under "Flere handlinger".
`deadline_date` på sagsplan-API'en flytter alene datoen og bevarer et eksisterende dansk klokkeslæt, også hen over urskift. Ny deadline får kl. 17. Datoændringer kræver sagsredigering og sagens aktuelle version. Sagsstart er fortsat en ren dato med valgfrit separat klokkeslæt. Ændringer opretter hverken bookinger eller tidsregistreringer.
Nye API'er ligger under `/api/v1/planner`: `queue`, `next-task`, `cases/{id}/assignees`, `cases/{id}/transfer`, `cases/{id}/planning`, `cases/{id}/summary`, `events`, `schedule`, `absences`, `capacity`, `unassigned`, `configuration`, `work-schedules`, `activity-types` og `team-scope`. Eksisterende endpoints og svarfelter er bevaret. Uaktuelle versioner giver HTTP 409. Audit og flertrinsændringer deler transaktion.
## Adgang og fortrolighed
Planlægningsadgang, medarbejderens tilladte teamomfang og sagsadgang kontrolleres på serveren. Særskilte rettigheder: `planner.view_team`, `planner.allocate`, `planner.absence_own`, `planner.absence_team`, `planner.absence_reason` og `cases.manage_confidential`.
Fortrolighedslåsen administreres fra sagsvisningen og `/api/v1/sag/{id}/visibility`. Adgang kan gives til udvalgte medarbejdere og grupper; øvrige brugere får 404 ved direkte adgang. Superadministrator har adgang. Ulåste sager beholder den hidtidige synlighed. En ulåsning bevarer versionshistorikken, så en gammel version ikke kan genbruges.
Beskyttelsen omfatter kendte sagslister, relationer, søgning, planlægning, JSON-API'er, kalenderfeeds og påmindelsesmodtagere. Skjulte reservationer bliver "Optaget" uden sag, kunde eller beskrivelse. Fraværsårsager maskeres uden særskilt adgang. Fortrolige sager udsendes ikke til vilkårlige eksterne mailadresser eller Mattermost-kanaler.
Deadlinekontrol genbruger det eksisterende reminderjob og opretter kun interne påmindelser. Nøglen deduplikerer pr. sag, deadline, allokeringstilstand og lokal dato. Ændringspåmindelser har særskilte hændelsesnøgler. Standardgrænsen før fast aftale er 30 minutter og kan administreres.
## Migration og drift
Anvend `1070_unified_planning.sql` efter 1069, derefter `1071_case_confidentiality.sql`. Kør med stop ved fejl og i én transaktion. Migrationerne er additive og kan genkøres; den første køinitialisering gentages ikke. PostgreSQL anvendes både lokalt og i produktion.
Før produktion:
1. Tag og verificér en databasebackup; afprøv migrationskørslen på en isoleret databasekopi.
2. Registrér kontroltal og ID'er for sager, tidsregistreringer og bookinger, og kontrollér dem efter migrationen. Kontrollér også én eventforbindelse pr. historisk booking og fravær uden fakturerbar tid.
3. Stands API og baggrundsskrivere under skemaændringen. En lokal prøve med samtidig API-læsning ramte en deadlock og blev rullet helt tilbage; den efterfølgende kørsel med standset API lykkedes.
4. Start API'en igen, så rettigheder og indstillinger indlæses på ny. Kontrollér de konkrete arbejdsgange med de relevante medarbejderroller.
5. Ved UI-tilbageførsel sættes `PLANNING_UI_ENABLED=false` i API-miljøet, og API'en genstartes. `/planning` viderestiller derefter til `/planner`; nye data og tabeller bevares. Undlad destruktiv down-migration. Fortrolighedskontrollen skal fortsat være aktiv.
Lokal backup: `/tmp/bmc_hub_before_planning_20261017.dump` (rettigheder 0600). Opbevaring uden for `/tmp` og produktionsbackup håndteres særskilt ved udrulning. Lokale kontroltal før/efter den afsluttende migration: 185 sager, 1.385 tidsregistreringer, 15 bookinger, alle 15 med eventforbindelse. Dette er ikke dokumentation for produktionsdrift.
## Validering
Kør databaseprøverne alene mod en dedikeret `bmc_planning_test_*`-database. Testfixturen afviser andre databasenavne og bruger syntetiske data. Den ændrer ikke udviklings- eller produktionsdata.
```sh
BMC_PLANNING_DB_TESTS=1 BMC_PLANNING_TEST_DATABASE=bmc_planning_test_20261017 \
PYTHONPYCACHEPREFIX=/tmp/bmc_planning_pycache ./.venv/bin/python -m pytest \
tests/test_unified_planning.py tests/test_planner.py tests/test_quick_time.py \
tests/test_quick_time_calendar.py tests/test_bottom_bar_timer_pause.py \
tests/test_case_commands.py tests/test_sag_v5.py -q --tb=short
```
Automatiseret dækning: tre medarbejderes uafhængige køer, stale versioner, reelle samtidige kø-/bookingændringer, delvis fordeling med atomisk rollback, omfordeling med auditfejl og bevaret ansvarlig, fælles mødeflytning med bevarede ID'er, fraværskonvertering, skjulte fraværsårsager, fortrolighed via bruger/gruppe, API-afvisning før skrivning, kapacitet/overlap, danske urskift, rene startdatoer, næste-opgave-advarsel uden tidsoprettelse, hoved-/undersager, reminder-deduplikering og pagineret søgning blandt 2.000 syntetiske sager. Relevante eksisterende timer-, sags-, kommandoflow- og selvstændig-tid-tests indgår.
Safari er kontrolleret lokalt efter genindlæsning: kø, dagkalender og kapacitetsvisning indlæses; den oprindelige "The string did not match the expected pattern" vises ikke længere. Den konkrete årsag var manglende migreret skema og et ikke-JSON-fejlsvar. Frontenden viser nu en forståelig fejl ved sådanne svar.
Endelig accept kræver fortsat fuld praktisk gennemgang af kravspecifikationens 20 scenarier og afsnit 31 med rigtige roller, mobilbetjening og det eksisterende faktureringsflow samt måling under produktionslignende belastning. Automatiserede testresultater og lokal Safari-kontrol erstatter ikke denne accept eller verificeret produktionsdrift. Afhængigheder mellem undersager og AI-forslag er bevidst udskudt.
Drag-handlerprøver: `node tests/test_planning_drag_ui.js` tester produktionens eventhandlers med isolerede DOM/API-adaptere: tildeling, overdragelse, start, deadline, uvedkommende drag og forældet ændring. Dette er ikke en erstatning for manuel drag-and-drop på alle browser-/mobilkombinationer.
## Lokal fejlretning: kalendertræk, 18. oktober 2026
Den lokale API-proces kørte ældre Python-kode, mens frontendfilerne allerede var opdateret. Den aktive kø og indbakke manglede `case_updated_at`, og datoflytningens backend var derfor ikke tilgængelig. Genstart af den lokale API indlæste ændringerne. Kontrol efter genstart viste versionsfeltet i begge læsemodeller samt fungerende gem/læs-tilbage af startdato og deadlinedato på en særskilt midlertidig testsag. Testsagen blev efterfølgende blødt slettet. En manglende version i browserens opgaveliste giver nu en konkret besked om at genindlæse siden; der sendes intet datokald uden version. Dette var en lokal runtime-fejl og dokumenterer ikke produktionsudrulning.
## Direkte kalenderhandlinger på kort, 18. oktober 2026
Hvert opgavekort har nu tre selvstændige knapper/trækhåndtag: Start, Deadline og Allokeret. De to første viser den gemte dato og eventuelt klokkeslæt. Allokeret viser antal kommende tidsrum for den valgte medarbejder; hvert tidsrum vises med fra/til på kortet. Den fælles dropvælger er fjernet. Et klik på knappen er alternativet til at trække.
Start og Deadline ændrer alene det tilsvarende sagsfelt. Allokeret åbner en enkel medarbejder/fra/til-dialog på den valgte kalenderdato; i timegitteret medtages det ramte klokkeslæt. Gem opretter en ny event/reservation via eksisterende API og konfliktbekræftelse. Gentagne handlinger tilføjer flere perioder og erstatter ikke tidligere. Bookingflytning i kalenderen påvirker fortsat kun den valgte event og dens deltagere.
Validering: 74 fokuserede Python-tests samt Node-handlerprøver består. Databasetesten opretter to perioder på samme sag, flytter den første og kontrollerer at den anden er uændret. UI-handlerprøver kontrollerer særskilt valg af Start/Deadline/Allokeret og de viste kortværdier. Lokal API genstartes efter Python-ændringer, og frontend-cacheversionen er opdateret.
Kalenderdrop er efterfølgende rettet til at finde dagen ud fra de renderede dagkolonners skærmposition, når tidsfeltet ikke selv har en dateret DOM-forælder. Samme hit bruges til markering under træk og ved drop. Regressionstesten rammer et tidsfelt uden `data-date`, vælger anden dagkolonne og kl. 09.30 samt kontrollerer, at et drop uden for kolonnerne ikke vælger en forkert dato.
Allokeret arbejdsreservation er nu en svag baggrundsfarve i kalenderens dag-/ugegitter. En diskret tekst kan åbne den eksisterende reservationsdialog. Kalenderens måneds-/listevisning beholder læsbare, nedtonede reservationsposter; møder forbliver almindelige aftaler. Registreret tid vises fortsat særskilt i lilla. Signatur og reservationsdialog forklarer, at allokering alene reserverer kapacitet, mens faktisk tid skal registreres med eksisterende timer/manuelle registrering. Ingen ændring i lagring, tidsregistrering eller fakturering. Renderprøver kontrollerer adskillelsen mellem baggrundsreservationer, møder og listevisning.
### Fraværsstatus og rapport (lokalt, 18. oktober 2026)
- Planlægning har knappen **Fraværsstatus og rapport**, der åbner `/planning/absence`. Det er en separat side uden yderligere planlægningsfaner.
- `planner.absence_report` giver rapport- og CSV-adgang; `planner.admin` og superadministrator har også adgang. Migration `1072_absence_reports.sql` tilføjer rettigheden og giver den til den eksisterende Administrators-gruppe. Rapportadgang udvider ikke medarbejderscope. `planner.absence_reason` styrer fortsat adgang til årsager; rapporten indeholder aldrig private noter.
- Rapportens periode er inklusive begge datoer, højst 367 dage. Medarbejderfilter og paginering genbruges i eksporten. CSV beskytter mod regnearksformler i tekstfelter.
- Planlagte timer og arbejdsdage afgrænses til perioden og arbejdstiden. Overlappende fravær tælles én gang i overblik og totaler, mens de oprindelige perioder fortsat kan ses separat. Faktisk fravær summeres fra ikke-afviste tidsregistreringer i den valgte periode og vises separat. Manglende arbejdstid markeres som ukendt. Der beregnes ikke feriesaldo.
- Flerdagesfravær vises tydeligt i kalenderens heldagsfelt og i en særskilt fraværsoversigt for den viste periode. Kalenderen gælder den valgte medarbejder. Nye hele fraværsdage angives med første og sidste dato; serveren omsætter datoerne til Europe/Copenhagen, inklusive sommer-/vintertid. Eksisterende tidsrum ændres ikke ved læsning.
- Lokal kontrol: 80 relevante Python-tests består mod isoleret PostgreSQL, samt `node tests/test_planning_drag_ui.js` og `node tests/test_absence_report_ui.js`. Safari-kontrol viser eksisterende ferieperioder i ugekalenderen og på rapporten. Migration 1072 er anvendt lokalt, og den lokale app er genstartet. Dette dokumenterer ikke produktionsudrulning.
Rapporten grupperer nu overblik og perioder efter fraværstype med medarbejdersubtotaler under hver type. CSV-eksporten følger samme rækkefølge. API'et har additivt `type_summary` og valgfrit `group_by=type`; eksisterende standardrækkefølge bevares for øvrige klienter. Skjulte årsager samles før gruppering under Fravær. Overlap fjernes inden for hver type og i det samlede overblik; overlap mellem forskellige typer kan derfor give højere sum af typer end samlet fravær. Lokal kontrol: 31 planlægningstests og rapportens JavaScript-test består; kørende lokalt API og opdateret HTML er kontrolleret. Browserens session var udløbet ved genindlæsning, så ny visuel kontrol kræver login.
Teamkalenderens fraværsvisning: `/api/v1/planner/period` returnerer nu additivt fraværsperioder med rettighedsfiltrerede labels og uden private noter. Den eksisterende teamkalender viser perioderne som diskrete baggrundsblokke i dag/uge og som markeringer pr. medarbejder i måned. Flerdagesperioder fordeles over hver berørt dag; slutdato ved midnat er eksklusiv. Kapacitetsberegningen ændres ikke. Validering: 41 relevante Python-tests samt `node tests/test_planner_absence_ui.js` består, inklusive adgangskontrol og dansk vintertid. Den lokale app blev genstartet efter ændringen. Ved den afsluttende genoptagelse var Docker ikke længere startet (docker.sock mangler); den sidste kontrol af den kørende visning kunne derfor ikke gennemføres. Ingen produktionsudrulning.
Supplerende lokal rettelse: Fraværsrapportens perioder og CSV har nu kolonnen Arbejdsdage. Sidetælleren viser desuden periodens samlede arbejdsdage, hvor overlap fortsat tælles én gang. Teamkalenderen har en synlig fraværsoversigt før medarbejderrækkerne med navn, type og datoer; klik ruller kun kalenderpanelet til medarbejderen. Kalender og sagsliste har på desktop afgrænset højde og ruller hver for sig, så lange sagslister ikke strækker kalenderen ud. Kalender-API'et er kontrolleret i den kørende lokale app og returnerer de eksisterende fraværsperioder; Safari viste ferie/sygdom og oversigtens knapper. Alle tre relevante JavaScript-prøver består. Sidste forsøg på screenshot efter scrollrettelsen fik timeout; appens health svarer HTTP 200. Ingen produktionsudrulning.
Dobbelt fravær af samme type: oprettelse og redigering af overlappende perioder for samme medarbejder afvises med HTTP 409. Eksisterende medarbejderlås serialiserer kontrollen, så samtidige gemninger ikke kan oprette dubletter. Perioder, der kun rører ved hinanden, andre medarbejderes ferie og forskellige typer bevarer den eksisterende adfærd. Tidligere dubletter slettes ikke automatisk; de kan annulleres gennem den eksisterende fraværsdialog.
Datovælgere i planlægningsdialoger, teamkalender og rapportfilter bruger Flatpickr 4.6.13 (allerede anvendt i tidsregistreringen). Bibliotek, dansk locale og MIT-licens ligger lokalt under `static/vendor/flatpickr/`. UI viser dansk datoformat, mandag først og 24-timers tid, men API-værdier bevarer deres ISO-format. Adapteren håndterer hele/delvise dage og krævede felter; tomme obligatoriske datoer kan ikke sendes. Der er ikke ændret globale datovælgere på andre sider.
Lokal validering: 43 relevante Python-tests består, inklusive samtidige ferieoprettelser, og fire JavaScript-prøver består (datovælger, rapport, drag og teamfravær). Appen er genstartet med overlapkontrollen. Safari-kontrol af rapportfilteret viser den danske månedskalender og I dag/Færdig. Ingen produktionsudrulning.

View File

@ -4,6 +4,7 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
## Aktive planer
- [ALSO Cloud Marketplace: korrekt periodefakturering](also-cloud-marketplace-periodefakturering.md) — beløbsfejl og linjetekst rettet lokalt; udvidet afstemning og fortjenestekontrol fortsat planlagt. Ikke lagt på produktion.
- [Indgående mail: ny arkitektur](indgaaende-mail-ny-arkitektur.md) — forslag til vurdering; fælles arbejdsindbakke, sikker sagskobling, varig behandling og kompatibel overgang. Ikke implementeret.
- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.

View File

@ -0,0 +1,279 @@
# Plan: Korrekt periodefakturering af ALSO Cloud Marketplace til e-conomic
Dato: 15. oktober 2026
Status: Delvist implementeret lokalt — beløbsfejl og linjetekst rettet; ikke lagt på serveren.
## 0. Afklaret omfang og aktuel kodegennemgang
Status pr. 15. oktober 2026: Brugeren har efterfølgende autoriseret rettelsen med »løs problemet« og bekræftet, at sagsreferencen skal udelades.
Lokalt implementeret: nye ALSO-kladder bruger antal 1 og godkendt periodebeløb, mens licensantal/månedspris bevares i metadata og kundetekst. Eksklusive perioder vises med sidste inkluderede dato og dage. Eksisterende pending-kladder uden eksterne dokumentnumre eller eksportforsøg normaliseres i læsevisningen uden databaseskrivning. Eksport validerer mod den gemte kladde, og stale browserdata, dublerede kildelinjer eller ændrede beløb afvises. ALSO-linjer undtages fra almindelig kataloggenberegning og låses i kladdeeditoren. Kildebeløb med mere end to betydende decimaler, negativt beløb, manglende tal eller rabat kræver afklaring; de overskrives ikke automatisk. Hvis råkilden kun har Charge og ingen særskilt månedspris, må Charge ikke præsenteres som månedspris; godkendelsen blokeres.
Ikke implementeret som del af denne rettelse: udvidet fortjenestemodel, ny automatisk genlæsning/afstemning efter eksport, kontrol af senere fakturakladder, historisk audit og ændringer af dokumenthoved/referencefelter. Den eksisterende interne Hub-reference bevares af hensyn til dubletbeskyttelsen. Ingen migration eller produktionsændring udført. Ekstern e-conomic-test og PDF-kontrol udestår.
**Omfanget er udelukkende ALSO Cloud Marketplace.** Andre abonnementer, manuelle ordrelinjer og integrationer bevarer eksisterende adfærd. Afgræns med eksisterende kildeidentitet (`source_type=also_cloud` og relation til importlinjen); produktnavn alene må ikke aktivere løsningen.
### Den konkrete fejl i den lokale kode
`app/modules/also/backend/service.py`, oprettelsen af `draft_lines`, gemmer både faktisk antal i `quantity`, månedspris i `unit_price` og kildebeløbet i `amount`. `app/modules/orders/backend/economic_export.py` bygger derimod e-conomic-linjen fra `quantity` og `unit_price` som `quantity` og `unitNetPrice`. Det særskilte periodebeløb bestemmer dermed ikke det eksporterede linjebeløb. Også `app/products/backend/economic_documents.py:line_snapshot` beregner nettobeløb ud fra antal gange enhedspris og rabat. Den fælles eksportkode må ikke ændres generelt for at rette ALSO.
Med oplæggets eksempel giver 116 × 67,08 kr. = 7.781,28 kr. for en linje, der skal være 7.028,25 kr. for 28 dage. Fejlen er 753,03 kr. på denne linje, hvis de nævnte værdier sendes uændret. Begge F3-periodelinjer vil tilsammen blive 15.562,56 kr. mod det forventede 7.781,28 kr., hvis begge eksporteres med fuldt antal og månedspris. Dette er en kodebaseret forklaring og regneeksempel, ikke en opgørelse over faktiske kundefakturaer. Den oprindelige Excel-fil og produktion er ikke kontrolleret her.
### Eksisterende arbejde, der skal genbruges
- ALSO-import og ordrekladder findes allerede med `line_key=also:<id>`, kilde-id og importrelationer.
- Periodebeskrivelse med linjeskift findes allerede. `_format_charge_period` viser i dag kildens slutdato direkte; eksklusiv slutdato skal håndteres afgrænset for dokumenterede kildeformater.
- Migrationerne 192/193 omhandler ALSO-grundlag og godkendelse; 245 eksportkvittering; 246 ordreopdatering/status; 247 ALSO-reference og periode; 248 periodelinjeskift. Filernes tilstedeværelse beviser ikke, at de er anvendt i produktion. Genbrug skema, og tilføj kun dokumenterede mangler.
- Ordre-routeren anvender både eksisterende eksportbeskyttelse og, når aktiveret, `app/workflows/export_guard.py`. Denne kræver ved afstemning den præcise Hub-reference i `references.other`. Referencen må ikke blot fjernes uden en kompatibel løsning på genfinding og dubletkontrol.
- `economic_documents.py` har snapshots og afstemning, men det er ikke dokumentation for, at alle krav i denne plan allerede opfyldes. Undersøg hele den aktive eksportvej før ændring.
- `ALSO_EFFECTIVE_COST_SQL` kan bruge produktets aktuelle leverandørpris gange antal og reserveværdien nul. Det dokumenterer ikke korrekt periodeindkøb; manglende kildeindkøb skal fremgå som ukendt, indtil reglerne er afklaret.
- `economic_write_policy.py` tillader oprettelse af ordrekladder og særskilt godkendte opdateringer. Planen udvider ikke tilladelser til at oprette, bogføre eller sende fakturaer. Fakturakontrol skal være læsende.
- Arbejdstræet var rent før dette dokumentationsarbejde. Ingen testkørsel eller databaseforbindelse indgår i kodegennemgangen.
### Åbne beslutninger før implementering
Disse punkter er ikke stiltiende godkendt. Afklar dem med brugeren, og dokumentér svar før den berørte funktion implementeres:
1. Om både Excel, CSV og eventuelle andre ALSO-importveje skal omfattes.
2. Hvilket kildefelt der er endeligt salgsbeløb, og om kundeaftaler/tillæg/manuelle rettelser fortsat kan ændre beløbet før godkendelse.
3. Om manglende dokumenteret indkøb skal blokere eksport eller alene vise ukendt fortjeneste. Aktuel produktpris må ikke antages at være historisk periodeindkøb.
4. Momsregler og valutaer; DKK og 25 % eksklusiv moms er endnu ikke bekræftet som universelle regler.
5. Om referenceforbuddet også omfatter dokumenthoved/referencefelter, og hvordan en intern eksportidentitet bevares uden synlig reference på PDF.
6. Om eksisterende fakturagebyr må tilføjes. Et godkendt gebyr skal indgå i afstemningen som forventet linje; det må ikke fejlagtigt tælles som en uventet abonnementslinje.
7. Hvor ordre-til-faktura-konvertering foretages i dag. Udgangspunktet er eksisterende arbejdsgang og ingen nye fakturaskrivninger fra Hub.
8. Om eksisterende ikke-eksporterede ALSO-kladder skal kunne omdannes via preview og ny godkendelse, eller kun nye importer omfattes. Ingen automatisk omskrivning foreslås.
9. Regler for gyldige nullinjer og negative reguleringer. Eksportvalideringen afviser i dag negativ enhedspris; denne plan autoriserer ikke en generel lempelse.
10. Startdato og kundekreds for historisk, læsende kontrol.
11. Tilgængelighed af separat e-conomic-testaftale, relevant layout og den oprindelige Norva24-fil. Eksempeltallene nedenfor stammer fra det indsendte oplæg og er ikke genvalideret mod Excel-filen.
### Arbejdspakker og beslutningsporte
A. Fastlæg kildefelter og beløbsregler med råfil og anonymiserede fixtures. Dokumentér en feltmapping og afklar ovenstående forretningsregler.
B. Spor alle aktive eksportveje, snapshots, prisvalidering, gebyrer, referencefelter, genforsøg og statusmodeller. Afklar via officiel e-conomic-dokumentation og testmiljø, hvordan linjetekst, læsning, konverteringsrelationer og PDF virker. API-adfærd er endnu ikke verificeret.
C. Udarbejd konkret ændringsdesign: afgrænset ALSO-normalisering til antal 1 og godkendt periodebeløb, bevaret faktisk antal i metadata/tekst, godkendt snapshot og uafhængig kontrolstatus. Den normale eksportstatus må ikke uden videre udvides med værdier, som ældre klienter ikke forstår. Kontrolgrundlaget skal omfatte hele det godkendte dokument, inklusive eventuelle tilladte gebyrer.
D. Implementér først efter særskilt implementeringsbesked. Gennemfør fokuserede regressionstests for begge eksportveje, prisvalidering, snapshots, genfinding uden synlig reference og eksisterende ALSO-kladder. En pris på hele perioden må ikke fejlfortolkes som ændret månedspris af eksisterende priskontrol.
E. Gennemfør den samlede test og visuelle fakturakontrol beskrevet nedenfor. Manglende eksternt testmiljø skal rapporteres som en manglende acceptkontrol.
F. Fremlæg resultat, opt-in-aktivering og tilbageførsel til særskilt produktionsgodkendelse. Historisk kontrol er en separat læsende arbejdspakke; eventuelle rettelser kræver særskilt beslutning.
Resten af dokumentet bevarer brugerens detaljerede krav. Hvor et krav afhænger af et åbent punkt ovenfor, skal afklaringen ske før implementering.
## 1. Mål og aftalt løsning
Ret BMC Hubs eksport af periodeopdelte abonnementer, så kundens beløb i e-conomic svarer præcist til de godkendte salgsbeløb i Hub.
På hver økonomisk varelinje bruges antal 1 og periodens samlede salgsbeløb som enhedspris. Under varelinjen vises tekst med det faktiske antal licenser, månedspris pr. licens, periode og antal fakturerede dage. Den forklarende tekst skal stå umiddelbart under den tilhørende varelinje på kundens faktura.
Ingen bestillingsreference, sagsreference eller teksten `Reference: sag-xx11` skal med i dette fakturalayout. Bevar eksisterende interne relationer og sporbarhed i Hub.
Hub skal beregne og vise indkøbsbeløb, salgsbeløb og fortjeneste internt. Indkøbspriser, fortjeneste og avanceprocenter må ikke sendes til e-conomic eller indgå i kundens fakturatekst.
Hub skal læse de oprettede dokumenter tilbage fra e-conomic og afstemme dem mod det godkendte grundlag. En eksportkvittering alene er ikke en bestået kontrol.
## 2. Afgrænsning og kompatibilitet
- Genbrug eksisterende ALSO-import, ordrekladder, eksport, dokumentrelationer og skabeloner.
- Begræns ændringen til de relevante importerede abonnementslinjer. Bevar adfærd for andre varelinjer og integrationer.
- Bevar eksisterende API-kontrakter. Eventuelle nye felter og statusværdier skal være additive og kompatible med eksisterende klienter.
- Hold test og produktion adskilt. Brug ingen rigtige kundedokumenter til test.
- Ingen automatisk bogføring, afsendelse, sletning eller ændring af historiske fakturaer.
- Denne plan autoriserer ikke implementering eller produktionsændringer.
## 3. Før programmering: undersøg den aktuelle løsning
AI-programmøren skal først:
1. Læse gældende AGENTS.md og undersøge eksisterende lokale ændringer.
2. Finde den nuværende import af antal, enhedspriser, periodebeløb og indkøbsbeløb.
3. Spore hele forløbet fra import til godkendt Hub-kladde, e-conomic-ordre og eventuel fakturakladde.
4. Undersøge eksisterende databasetabeller og migrationshistorik, før nye felter foreslås.
5. Kontrollere den aktuelle e-conomic API og eksisterende klient: linjetekst med linjeskift, tekstlinjer, afrunding, dokumentlæsning, moms og konvertering fra ordre til fakturakladde.
6. Afklare præcis hvilke dokumenttrin integrationen allerede udfører. Bevar eksisterende sikkerhedsregler og godkendelser.
7. Dokumentere, hvad der allerede virker, og hvad der mangler, inden ændringen gennemføres.
Mulige undersøgelsessteder fra tidligere arbejde, som skal verificeres i den aktuelle kode: `app/modules/also/backend/service.py`, ordre-modulets eksportfunktioner samt `economic_export.py` og `economic_documents.py`.
## 4. Datagrundlag og beregninger
Bevar følgende oplysninger separat pr. kildelinje:
| Oplysning | Anvendelse |
|---|---|
| Kunde og produkt | Match og fakturabeskrivelse |
| Kilde-id, import-id og abonnements-id | Sporbarhed og beskyttelse mod dubletter |
| Faktisk licensantal | Forklarende kundetekst og intern kontrol |
| Salgspris pr. licens pr. måned | Forklarende kundetekst |
| Indkøbspris pr. licens pr. måned | Intern beregningskontrol |
| Periodestart og eksklusiv periodeslut | Periodeberegning |
| Fakturerede dage og beregningsgrundlag | Forklaring og kontrol |
| Godkendt salgsbeløb for perioden | Autoritativt beløb ved eksport |
| Godkendt indkøbsbeløb for perioden | Intern fortjeneste |
| Valuta og momsbehandling | Afstemning og fakturering |
Brug decimalberegning til penge. Afrund efter dokumenteret leverandør- og e-conomic-praksis. Bevar rå kildeoplysninger og det godkendte eksportgrundlag, så senere prisændringer ikke ændrer historiske kontroller.
For august-eksemplet kan beløbet kontrolleres som:
`antal licenser × månedspris × fakturerede dage / 31`
Dette er en valideret regel for den undersøgte fil, ikke en universel regel for alle abonnementer. Brug kildens dokumenterede periodiseringsregel for andre måneder og produkter. Ved manglende eller modstridende grundlag kræves afklaring før eksport; ingen tavs nulstilling eller automatisk overskrivning af kildebeløbet.
Periodens godkendte salgsbeløb er eksportens beløb. En afvigelse mellem kildebeløb og kontrolberegning skal vises og afklares før godkendelse.
## 5. Fakturalayout og eksport
For hver godkendt kildelinje:
- Opret én økonomisk varelinje med antal 1, enhedspris lig periodens salgsbeløb og ingen perioderabat.
- Vis produktnavnet på varelinjen.
- Vis faktisk antal, månedspris, periode og dage under varelinjen.
- Brug helst den eksisterende linjebeskrivelse med linjeskift, hvis den vises korrekt i e-conomic og på PDF. Brug ellers en tilknyttet tekstlinje, hvis API og eksisterende integration understøtter det.
- Forklarende tekst må ikke oprette et ekstra beløb eller løsne sig fra sin varelinje.
- Medtag ingen sags- eller bestillingsreference i kundeteksten.
- Bevar periodeopdelte linjer. Sammenlægning er ikke en del af denne ændring.
Kildens slutdato er eksklusiv. `01.08.2026–29.08.2026` skal derfor vises som `01.08.–28.08.2026`. Bevar det oprindelige interval internt, og håndter også månedsskift, årsskift og skudår korrekt.
### Eksempel: Norva24, august 2026
Alle beløb i DKK. Momsbehandling skal bekræftes særskilt.
| Varelinje | Antal på varelinjen | Enhedspris | Linjebeløb |
|---|---:|---:|---:|
| Microsoft 365 F3 | 1 | 7.028,25 | 7.028,25 |
| Microsoft 365 F3 | 1 | 753,03 | 753,03 |
| Microsoft Defender Suite Add-on FLW | 1 | 6.543,55 | 6.543,55 |
| Microsoft Defender Suite Add-on FLW | 1 | 701,09 | 701,09 |
| Exchange Online – Plan 1 | 1 | 17,10 | 17,10 |
| Microsoft 365 Business Basic | 1 | 19,46 | 19,46 |
| **Sum af salgsbeløb** | | | **15.062,48** |
Tekst direkte under de seks varelinjer, i samme rækkefølge:
1. `116 licenser á 67,08 kr./måned. Periode: 01.08.–28.08.2026, 28 af 31 dage.`
2. `116 licenser á 67,08 kr./måned. Periode: 29.08.–31.08.2026, 3 af 31 dage.`
3. `108 licenser á 67,08 kr./måned. Periode: 01.08.–28.08.2026, 28 af 31 dage.`
4. `108 licenser á 67,08 kr./måned. Periode: 29.08.–31.08.2026, 3 af 31 dage.`
5. `1 licens á 27,90 kr./måned. Periode: 13.08.–31.08.2026, 19 af 31 dage.`
6. `1 licens á 50,28 kr./måned. Periode: 20.08.–31.08.2026, 12 af 31 dage.`
Hvis priserne er eksklusive moms, og 25 % moms gælder, bliver moms 3.765,62 kr. og total 18.828,10 kr. Filen dokumenterer ikke selv momsbehandlingen.
## 6. Fortjeneste: kun i Hub
Beregn pr. linje, dokument og kunde for den relevante periode:
- Fortjeneste i kroner = godkendt salgsbeløb minus godkendt indkøbsbeløb.
- Dækningsgrad = fortjeneste / salgsbeløb × 100. Ved nul i salgsbeløb vises procenten som ikke beregnelig.
- Hvis avance i procent også vises, skal den navngives særskilt: fortjeneste / indkøbsbeløb × 100. Bland ikke de to procenter sammen.
- Brug samme valuta og sammenlignelige beløb uden moms. Manglende indkøbspris må ikke blive til en falsk fortjeneste.
- Betegn beløbet som fortjeneste før øvrige omkostninger.
Norva24-eksemplet skal i Hub vise:
| Post | Beløb |
|---|---:|
| Indkøbsbeløb | 13.095,14 kr. |
| Salgsbeløb | 15.062,48 kr. |
| Fortjeneste før øvrige omkostninger | 1.967,34 kr. |
Genbrug eksisterende økonomifelter og adgangsrettigheder. Tilføj kun manglende felter efter skemagennemgang. Fortjeneste og indkøb må hverken findes i eksportdata, fritekst, noter eller kundevendte bilag til e-conomic.
## 7. Kontrol efter oprettelse i e-conomic
Gem et godkendt eksportgrundlag med linjer, beløb, valuta, moms og en versionsidentifikation. Knyt det til de konkrete eksterne dokument-id'er.
Efter oprettelse skal Hub hente dokumentet direkte fra e-conomic og kontrollere:
1. Rigtig kunde, valuta og dokumenttype.
2. Alle forventede økonomiske linjer findes præcis én gang. Ingen uventede ekstra linjer.
3. Antal er 1, enhedspris svarer til periodebeløbet, og rabat er som forventet.
4. Hvert linjebeløb matcher det godkendte beløb i valutaens mindste enhed.
5. Produkt, faktisk licensantal, månedspris, periode og dage fremgår korrekt og er knyttet til den rigtige linje.
6. Ingen reference, indkøbspris eller fortjeneste er sendt med i de berørte linjer.
7. Sum før moms matcher Hub. Moms og total matcher dokumentets godkendte momsbehandling og afrundingsregler.
Afstem linjer gennem gemte relationer til eksterne linje-id'er, hvor muligt. Stol ikke alene på rækkefølge, produktnavn eller oprindeligt fakturalinjenummer. Håndter tekstlinjer særskilt fra økonomiske linjer.
Hvis første eksport opretter en ordre, skal både ordren og den senere fakturakladde kontrolleres. En kontrolleret ordre beviser ikke, at den konverterede faktura er korrekt.
Ved manglende adgang til fakturakladden vises `Fakturakladde ikke kontrolleret`. Vis aldrig en faktura som korrekt alene på baggrund af en ordrekontrol.
Kontrollen gælder dokumentets aktuelle version. Efter kendte redigeringer, ny konvertering eller før en videre handling gennem Hub skal kontrollen gentages. Gem kontroltidspunkt og hvilket dokumentindhold kontrollen omfattede. Undersøg eksisterende hændelser og muligheder for at opdage eksterne ændringer; en gammel kontrol må ikke fremstilles som en garanti for senere ændringer.
## 8. Status, afvigelser og genforsøg
Genbrug eksisterende statusmodel, og supplér kun efter behov med tydelige tilstande:
- Klar til overførsel.
- Overførsel i gang.
- Oprettet i e-conomic — kontrol afventer.
- Kontrol bestået, med dokumenttype og tidspunkt.
- Afvigelse fundet.
- Kontrol kunne ikke udføres.
- Dokument ændret — ny kontrol kræves.
Vis forventet værdi, faktisk værdi, difference og berørt linje ved fejl. Hub må ikke markere dokumentet som korrekt eller tillade en afhængig videre godkendelse ved afvigelser eller ukendt kontrolstatus.
En spærring i Hub forhindrer ikke i sig selv manuelle handlinger direkte i e-conomic. Gør dette tydeligt ved fejl; lov ikke en ekstern spærring, som integrationen ikke kan håndhæve.
Ved timeout eller usikkert oprettelsesresultat skal Hub først afklare, om dokumentet allerede findes. Ingen blind genoprettelse. Brug eksisterende dubletbeskyttelse og en stabil eksportidentitet, og gem eksternt dokument-id så tidligt som muligt. En teknisk kontrolfejl skal kunne genforsøges uden ny faktura eller ordre.
## 9. Test og acceptkriterier
Kør relevante eksisterende tests og tilføj fokuserede tests for:
- Norva24: seks økonomiske linjer, subtotal 15.062,48 kr., interne indkøb 13.095,14 kr. og fortjeneste 1.967,34 kr.
- Korrekt forklarende tekst under alle linjer uden `sag-xx11` eller andre bestillingsreferencer.
- Ingen interne indkøbs- eller fortjenesteoplysninger i eksport eller kundebilag.
- Delmåned, hel måned, februar, skudår, månedsskift og årsskift.
- Gyldigt nul, manglende antal/priser, negativ regulering efter eksisterende regler og modstridende kildebeløb.
- Afrunding pr. linje og dokument; ingen generel tolerance, der skjuler en forskel på én øre.
- Forkert kunde, beløb, valuta, moms, tekst, ekstra linje, manglende linje og dublet i et tilbagesendt dokument.
- API-fejl, timeout efter oprettelse, gentagne klik og samtidige eksportforsøg uden dubletter.
- Konvertering fra ordre til fakturakladde og redigering efter en bestået kontrol.
- Eksisterende eksport af andre varetyper uden ændret adfærd.
Gennemfør desuden en samlet test i et adskilt testmiljø: import, godkendelse, eksport, genlæsning, kontrol og fakturavisning/PDF. Bekræft visuelt, at kundeteksten står umiddelbart under den rigtige varelinje, og at kroner, perioder og danske tegn vises korrekt.
Accept kræver både korrekte eksporterede værdier, bestået kontrol af det genlæste dokument og læsbar fakturavisning. En mocktest alene dokumenterer ikke e-conomics faktiske adfærd.
## 10. Implementeringsrækkefølge og tilbageførsel
1. Dokumentér nuværende adfærd og eksisterende arbejde. Opret isoleret gren/checkpoint.
2. Fastlæg og test eksportgrundlag, beløbsregler og intern fortjeneste.
3. Tilføj fakturalayout og afgrænset eksportændring bag en opt-in-indstilling.
4. Implementér genlæsning, afstemning, dokumentrelationer og sikker håndtering af genforsøg.
5. Start eventuelt med intern kontrol i observationstilstand, hvor kontrollens resultat registreres uden at fremstille eksporten som fuldt verificeret.
6. Gennemfør tests og visuelt eftersyn i testmiljøet.
7. Fremlæg konkret ændring, testresultater og produktionsplan til godkendelse før idriftsættelse.
Hvis databaseændringer er nødvendige, skal de være additive. Forklar ændringerne først, tag backup før migration og dokumentér tilbageførsel. Ingen sletning eller omskrivning af historiske økonomiposter.
Ved tilbageførsel deaktiveres den nye eksport for nye dokumenter. Bevar oprettede dokumentrelationer og kontrolhistorik. En tilbageførsel må ikke automatisk geneksportere eksisterende dokumenter eller genåbne fejlramte dokumenter til ukontrolleret behandling.
## 11. Eksisterende og historiske dokumenter
Planlæg en separat, læsende afstemning af allerede eksporterede relevante dokumenter efter kildegrundlag og e-conomic-data. Find manglende, dobbelte eller fejlberegnede linjer, og opgør forskelle pr. dokument og kunde.
Historiske ændringer kræver særskilt godkendelse. Ret ikke bogførte fakturaer ved at overskrive Hub-data. Eventuelle krediteringer eller nye fakturaer skal følge den eksisterende kontrollerede regnskabsproces. Opgør ikke faktisk over- eller underbetaling uden at afstemme fakturaer, korrektioner og betalinger.
## 12. Aflevering fra AI-programmøren
Aflever en kort dansk status med:
- Hvad der er implementeret, og hvilke dele der stadig er planlagt.
- Berørte komponenter og eventuelle additive migrationer.
- Testresultater og dokumentation fra e-conomic-test samt fakturavisning.
- Dokumenteret dubletbeskyttelse og kontrol ved fejl.
- Aktivering, tilbageførsel og kendte begrænsninger.
- Tydelig forskel på rettet lokalt, testet i testmiljø og lagt på produktion.
## Kildegrundlag
Brugerens aftaler i denne samtale og den tidligere gennemgåede `Invoice_Aggregation-6.xlsx`, fanen `Worksheet - 02.09.26 - 08.23.44`, Norva24-rækkerne 6–11. Tallene er fra den tidligere filkontrol i samtalen. Den aktuelle kode, skemastruktur og e-conomic API er ikke gennemgået som del af denne planfil og skal verificeres under punkt 3.

13
main.py
View File

@ -135,6 +135,8 @@ from app.modules.telefoni.frontend import views as telefoni_views
from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
from app.modules.planner.backend import router as planner_api
from app.modules.planner.backend import work as planner_work, calendar as planner_calendar, capacity as planner_capacity, configuration as planner_configuration
from app.modules.sag.backend import visibility as case_visibility_api
from app.modules.planner.frontend import views as planner_views
from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
@ -498,7 +500,11 @@ async def auth_middleware(request: Request, call_next):
content={"detail": "Invalid token"}
)
return await call_next(request)
from app.core.case_visibility import protect_request
return await protect_request(request,call_next,{
'id':getattr(request.state,'user_id',None),
'is_superadmin':bool(payload.get('is_superadmin') or payload.get('shadow_admin')),
})
# Include routers
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
@ -562,6 +568,11 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devp
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(planner_api.router, prefix="/api/v1", tags=["Planner"])
app.include_router(planner_work.router, prefix="/api/v1/planner", tags=["Planner"])
app.include_router(planner_calendar.router, prefix="/api/v1/planner", tags=["Planner"])
app.include_router(planner_capacity.router, prefix="/api/v1/planner", tags=["Planner"])
app.include_router(planner_configuration.router, prefix="/api/v1/planner", tags=["Planner"])
app.include_router(case_visibility_api.router, prefix="/api/v1", tags=["Cases"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"])

View File

@ -0,0 +1,166 @@
-- Additive planning extension, after 1069. Existing case, booking and time IDs are preserved.
ALTER TABLE sag_case_assignees
ADD COLUMN IF NOT EXISTS estimated_minutes INTEGER CHECK (estimated_minutes >= 0),
ADD COLUMN IF NOT EXISTS remaining_minutes INTEGER CHECK (remaining_minutes >= 0),
ADD COLUMN IF NOT EXISTS queue_position NUMERIC NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS work_status TEXT NOT NULL DEFAULT 'ready'
CHECK (work_status IN ('ready','working','paused','waiting','done')),
ADD COLUMN IF NOT EXISTS snoozed_until TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE sag_sager ADD COLUMN IF NOT EXISTS start_time TIME;
ALTER TABLE planner_settings ADD COLUMN IF NOT EXISTS appointment_warning_minutes INTEGER NOT NULL DEFAULT 30;
ALTER TABLE planner_settings ADD COLUMN IF NOT EXISTS deadline_warning_days INTEGER NOT NULL DEFAULT 1;
ALTER TABLE planner_settings ADD COLUMN IF NOT EXISTS queue_initialized BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS planner_queue_versions (
user_id INTEGER PRIMARY KEY REFERENCES users(user_id), version INTEGER NOT NULL DEFAULT 1
);
DO $$ BEGIN
IF NOT COALESCE((SELECT queue_initialized FROM planner_settings WHERE id=1),FALSE) THEN
INSERT INTO sag_case_assignees(sag_id,user_id)
SELECT s.id,s.ansvarlig_bruger_id FROM sag_sager s
JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND LOWER(s.status) NOT IN ('afsluttet','lukket','løst','resolved','closed','done','arkiveret','archived','annulleret','cancelled')
ON CONFLICT DO NOTHING;
INSERT INTO sag_case_assignees(sag_id,user_id)
SELECT DISTINCT a.sag_id,a.user_id FROM planner_allocations a
JOIN sag_sager s ON s.id=a.sag_id
WHERE a.deleted_at IS NULL AND s.deleted_at IS NULL
AND LOWER(s.status) NOT IN ('afsluttet','lukket','løst','resolved','closed','done','arkiveret','archived','annulleret','cancelled')
ON CONFLICT DO NOTHING;
WITH ranked AS (
SELECT a.sag_id,a.user_id,ROW_NUMBER() OVER (PARTITION BY a.user_id ORDER BY
CASE s.priority::text WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,
s.deadline NULLS LAST,s.id)*1024 AS position
FROM sag_case_assignees a JOIN sag_sager s ON s.id=a.sag_id
)
UPDATE sag_case_assignees a SET queue_position=r.position FROM ranked r
WHERE a.sag_id=r.sag_id AND a.user_id=r.user_id AND a.queue_position=0;
INSERT INTO planner_settings(id,queue_initialized) VALUES(1,TRUE)
ON CONFLICT(id) DO UPDATE SET queue_initialized=TRUE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS planner_queue_order ON sag_case_assignees(user_id,pinned DESC,queue_position,sag_id);
-- Legacy assignment routes also invalidate queue snapshots and append new members.
CREATE OR REPLACE FUNCTION planner_assignee_membership() RETURNS trigger AS $$
DECLARE uid INTEGER;
BEGIN
uid=CASE WHEN TG_OP='DELETE' THEN OLD.user_id ELSE NEW.user_id END;
PERFORM id FROM sag_sager WHERE id=CASE WHEN TG_OP='DELETE' THEN OLD.sag_id ELSE NEW.sag_id END FOR KEY SHARE;
PERFORM pg_advisory_xact_lock(252,uid);
INSERT INTO planner_queue_versions(user_id,version) VALUES(uid,2)
ON CONFLICT(user_id) DO UPDATE SET version=planner_queue_versions.version+1;
IF TG_OP='INSERT' AND NEW.queue_position=0 THEN
SELECT COALESCE(MAX(queue_position),0)+1024 INTO NEW.queue_position FROM sag_case_assignees WHERE user_id=uid;
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS planner_assignee_membership ON sag_case_assignees;
CREATE TRIGGER planner_assignee_membership BEFORE INSERT OR DELETE ON sag_case_assignees
FOR EACH ROW EXECUTE FUNCTION planner_assignee_membership();
CREATE TABLE IF NOT EXISTS planner_events (
id BIGSERIAL PRIMARY KEY, sag_id INTEGER REFERENCES sag_sager(id),
title TEXT NOT NULL, description TEXT, kind TEXT NOT NULL DEFAULT 'work',
starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL,
version INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(user_id),
updated_by INTEGER REFERENCES users(user_id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ,
CHECK(ends_at>starts_at)
);
ALTER TABLE planner_allocations ALTER COLUMN sag_id DROP NOT NULL;
ALTER TABLE planner_allocations ADD COLUMN IF NOT EXISTS event_id BIGINT REFERENCES planner_events(id);
-- One event per legacy booking, deliberately no inference of shared meetings.
DO $$ DECLARE a RECORD; eid BIGINT; BEGIN
FOR a IN SELECT p.*,s.titel FROM planner_allocations p JOIN sag_sager s ON s.id=p.sag_id WHERE event_id IS NULL LOOP
INSERT INTO planner_events(sag_id,title,description,starts_at,ends_at,created_by,updated_by,deleted_at)
VALUES(a.sag_id,a.titel,a.note,a.starts_at,a.ends_at,a.created_by,a.updated_by,a.deleted_at) RETURNING id INTO eid;
UPDATE planner_allocations SET event_id=eid WHERE id=a.id;
END LOOP;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS planner_event_participant ON planner_allocations(event_id,user_id) WHERE deleted_at IS NULL;
-- Legacy booking writes stay in sync with the shared event model.
CREATE OR REPLACE FUNCTION planner_booking_event() RETURNS trigger AS $$
DECLARE eid BIGINT; parent planner_events%ROWTYPE;
BEGIN
IF NEW.deleted_at IS NULL AND NEW.sag_id IS NOT NULL AND (TG_OP='INSERT' OR NEW.ends_at>NOW()) THEN
INSERT INTO sag_case_assignees(sag_id,user_id,assigned_by_user_id)
VALUES(NEW.sag_id,NEW.user_id,NEW.updated_by) ON CONFLICT DO NOTHING;
END IF;
IF NEW.event_id IS NULL THEN
INSERT INTO planner_events(sag_id,title,description,starts_at,ends_at,created_by,updated_by,deleted_at)
VALUES(NEW.sag_id,COALESCE((SELECT titel FROM sag_sager WHERE id=NEW.sag_id),'Aftale'),
NEW.note,NEW.starts_at,NEW.ends_at,NEW.created_by,NEW.updated_by,NEW.deleted_at) RETURNING id INTO eid;
UPDATE planner_allocations SET event_id=eid WHERE id=NEW.id;
ELSE
SELECT * INTO parent FROM planner_events WHERE id=NEW.event_id FOR UPDATE;
IF NEW.deleted_at IS NOT NULL THEN
IF parent.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM planner_allocations WHERE event_id=NEW.event_id AND deleted_at IS NULL) THEN
UPDATE planner_events SET deleted_at=NOW(),version=version+1 WHERE id=NEW.event_id;
END IF;
RETURN NEW;
END IF;
IF parent.starts_at IS DISTINCT FROM NEW.starts_at OR parent.ends_at IS DISTINCT FROM NEW.ends_at
OR parent.sag_id IS DISTINCT FROM NEW.sag_id OR parent.description IS DISTINCT FROM NEW.note THEN
IF (SELECT COUNT(*) FROM planner_allocations WHERE event_id=NEW.event_id AND deleted_at IS NULL)>1 THEN
RAISE EXCEPTION 'Shared appointments must be moved through the event API';
END IF;
UPDATE planner_events SET starts_at=NEW.starts_at,ends_at=NEW.ends_at,sag_id=NEW.sag_id,description=NEW.note,
version=version+1,updated_by=NEW.updated_by,updated_at=NOW() WHERE id=NEW.event_id;
END IF;
IF parent.deleted_at IS NOT NULL THEN
UPDATE planner_events SET deleted_at=NULL,version=version+1 WHERE id=NEW.event_id;
END IF;
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS planner_booking_event ON planner_allocations;
CREATE TRIGGER planner_booking_event AFTER INSERT OR UPDATE ON planner_allocations
FOR EACH ROW EXECUTE FUNCTION planner_booking_event();
ALTER TABLE planner_allocation_history ALTER COLUMN allocation_id DROP NOT NULL;
ALTER TABLE planner_allocation_history ALTER COLUMN sag_id DROP NOT NULL;
ALTER TABLE planner_allocation_history ADD COLUMN IF NOT EXISTS entity_type TEXT DEFAULT 'allocation';
ALTER TABLE planner_allocation_history ADD COLUMN IF NOT EXISTS entity_id BIGINT;
CREATE TABLE IF NOT EXISTS planner_activity_types (
code TEXT PRIMARY KEY, label TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE,
is_absence BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO planner_activity_types(code,label) VALUES
('vacation','Ferie'),('sickness','Sygdom'),('child_sickness','Barns sygedag'),
('time_off','Afspadsering'),('course','Kursus'),('absence','Andet fravær') ON CONFLICT DO NOTHING;
INSERT INTO planner_activity_types(code,label,is_absence) VALUES
('customer_work','Kundearbejde',FALSE),('internal_work','Internt arbejde',FALSE),
('internal_meeting','Internt møde',FALSE),('customer_meeting','Kundemøde',FALSE) ON CONFLICT DO NOTHING;
INSERT INTO planner_activity_types(code,label)
SELECT DISTINCT kind,kind FROM planner_absences WHERE kind IS NOT NULL ON CONFLICT DO NOTHING;
ALTER TABLE planner_absences ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE planner_absences ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE planner_absences ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(user_id);
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS planner_absence_id BIGINT REFERENCES planner_absences(id);
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS planner_activity_code TEXT REFERENCES planner_activity_types(code);
CREATE UNIQUE INDEX IF NOT EXISTS planner_actual_absence_day ON tmodule_times(planner_absence_id,worked_date) WHERE planner_absence_id IS NOT NULL;
DROP INDEX IF EXISTS planner_actual_absence;
DO $$ BEGIN
IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname='planner_absence_nonbillable' AND conrelid='tmodule_times'::regclass) THEN
ALTER TABLE tmodule_times ADD CONSTRAINT planner_absence_nonbillable
CHECK(planner_absence_id IS NULL OR (billable IS FALSE AND COALESCE(fakturerbar_tid_min,0)=0));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS planner_time_employee_case ON tmodule_times(sag_id,medarbejder_id) WHERE status<>'rejected';
CREATE INDEX IF NOT EXISTS planner_live_absence_period ON planner_absences(user_id,starts_at,ends_at) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS planner_reminder_key ON sag_reminders((trigger_config->>'planner_key'))
WHERE trigger_config ? 'planner_key';
INSERT INTO permissions(code,description,category) VALUES
('planner.view_team','Se tilladte kollegers planer','planner'),
('planner.allocate','Allokere medarbejdere','planner'),
('planner.absence_own','Registrere eget fravær','planner'),
('planner.absence_team','Registrere andres fravær','planner'),
('planner.absence_reason','Se fraværsårsager','planner') 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='Administrators' AND p.category='planner' 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 ('planner.view_team','planner.allocate','planner.absence_own','planner.absence_team'))
OR (g.name='Technicians' AND p.code='planner.absence_own') ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,24 @@
-- Case privacy is additive; absence of a row preserves current case visibility.
CREATE TABLE IF NOT EXISTS sag_case_visibility (
sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE CASCADE,
restricted BOOLEAN NOT NULL DEFAULT TRUE,
version INTEGER NOT NULL DEFAULT 1,
updated_by INTEGER REFERENCES users(user_id), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sag_case_visibility_users (
sag_id INTEGER NOT NULL REFERENCES sag_case_visibility(sag_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
PRIMARY KEY(sag_id,user_id)
);
CREATE TABLE IF NOT EXISTS sag_case_visibility_groups (
sag_id INTEGER NOT NULL REFERENCES sag_case_visibility(sag_id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY(sag_id,group_id)
);
CREATE INDEX IF NOT EXISTS sag_visibility_user ON sag_case_visibility_users(user_id,sag_id);
CREATE INDEX IF NOT EXISTS sag_visibility_group ON sag_case_visibility_groups(group_id,sag_id);
INSERT INTO permissions(code,description,category) VALUES
('cases.manage_confidential','Administrere fortrolige sagers adgang','cases') 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='Administrators' AND p.code='cases.manage_confidential' ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,8 @@
-- Additive permission only. Rollback: hide the report UI; retain permissions/data.
INSERT INTO permissions(code,description,category) VALUES
('planner.absence_report','Se og eksportere fraværsrapporter for tilladte medarbejdere','planner')
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='Administrators' AND p.code='planner.absence_report'
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,10 @@
-- Durable customer-machine links; no changes to existing sessions during migration.
CREATE TABLE IF NOT EXISTS anydesk_machine_links (
anydesk_id VARCHAR(64) PRIMARY KEY CHECK (anydesk_id ~ '^[0-9]+$'),
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
source_session_id INTEGER REFERENCES anydesk_sessions(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_anydesk_machine_links_customer ON anydesk_machine_links(customer_id);

View File

@ -0,0 +1,5 @@
-- BMC's own asset purpose, separate from hardware type and rental status.
-- Existing assets remain unclassified; customer hardware is not reclassified.
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS bmc_asset_category VARCHAR(20)
CHECK (bmc_asset_category IN ('rental', 'internal'));

View File

@ -0,0 +1,11 @@
-- Hardware-only preview snapshots; no accounting status or invoice amount updates.
CREATE TABLE IF NOT EXISTS hardware_intake_previews (
id UUID PRIMARY KEY,
source_kind VARCHAR(20) NOT NULL CHECK(source_kind IN ('invoice','file')),
source_id BIGINT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_hardware_intake_unit
ON hardware_assets ((hardware_specs->>'intake_unit_key'))
WHERE hardware_specs->>'intake_unit_key' IS NOT NULL;

View File

@ -12,8 +12,32 @@
.case-v5 .v5-case-facts .contact-widget-field{min-width:0}.case-v5 .v5-case-facts .contact-widget-row{flex-wrap:wrap}
.case-v5 #caseTabs{margin:0 0 14px!important;padding:0!important;border-bottom:1px solid var(--case-border);border-radius:0!important;background:transparent!important;gap:2px;display:flex;flex-wrap:wrap}
.case-v5 #caseTabs .nav-link{font-size:12px!important;padding:9px 8px!important;background:transparent!important;border-radius:0!important;box-shadow:none!important;border:0!important}
.case-v5 .case-planner-shell{grid-template-columns:minmax(0,1fr)}
.case-v5 #planner .row>div:has(#casePlannerCount){display:none}
.case-v5 #planner .row>div:has(.case-planner-kpi){flex:1;max-width:none}
.case-v5 .case-planner-kpi{padding:.45rem .65rem}
.case-v5 .v5-planner-subcases>summary{cursor:pointer;font-size:.85rem;color:var(--text-color,#56677a)}
.case-v5 #casePlannerAllocationForm[hidden]{display:none!important}
.case-v5 #planner .case-planner-card-header{flex-wrap:wrap}
.case-v5 #planner .case-planner-card-header>div{flex:1;min-width:220px}
.case-v5 #planner .case-planner-calendar:first-of-type{margin-top:0}
.case-v5 .v5-planner-overview>summary{cursor:pointer;font-size:.85rem;color:var(--text-color,#56677a)}
.case-v5 .v5-planner-overview .row{margin-top:.5rem}
.case-v5 #planner .case-planner-week.v5-time-grid{display:grid;grid-template-columns:52px repeat(var(--week-days),minmax(110px,1fr));min-width:650px;overflow:visible;padding-bottom:14px}
.case-v5 #planner .case-planner-calendar{overflow-x:auto}
.case-v5 .v5-time-heading,.case-v5 .v5-time-corner{padding:10px 6px;border-bottom:1px solid var(--case-border);font-size:12px;font-weight:600;text-align:center}
.case-v5 .v5-time-heading.is-today{background:var(--theme-surface-soft,#e8f4fa);box-shadow:inset 0 3px var(--case-primary)}
.case-v5 .v5-time-labels{position:relative;font-size:11px;color:var(--text-color,#56677a)}
.case-v5 .v5-time-labels span{position:absolute;right:6px;transform:translateY(-50%)}
.case-v5 .v5-time-labels span:first-child{transform:none}
.case-v5 .v5-time-day{position:relative;border-right:1px solid var(--case-border);background:repeating-linear-gradient(to bottom,transparent 0,transparent 47px,var(--case-border) 47px,var(--case-border) 48px,transparent 48px,transparent 95px,var(--case-border) 95px,var(--case-border) 96px);cursor:crosshair}
.case-v5 .v5-time-event{position:absolute;box-sizing:border-box;margin:0;padding:2px 5px 10px;min-height:24px;overflow:hidden;cursor:default;line-height:1.2;z-index:1}
.case-v5 .v5-time-event strong{display:block;font-size:11px}
.case-v5 .v5-time-event .case-slot-resize{position:absolute;bottom:0;left:0;height:10px;margin:0;padding:0;border:0;line-height:8px;background:var(--theme-surface-soft,#d5eaf5)}
.case-v5 .case-slot-resize{display:block;width:100%;margin-top:6px;padding:5px 2px;border:0;border-top:1px solid currentColor;background:transparent;color:inherit;font:inherit;font-size:11px;cursor:ns-resize;touch-action:none;user-select:none}
.case-v5 .case-slot-resize:is(:hover,:focus-visible,.is-resizing){background:var(--theme-surface-soft,#d5eaf5);outline:2px solid var(--case-primary);border-radius:4px}
@media(max-width:767px){.case-v5 #planner .case-planner-week{min-width:0;grid-template-columns:1fr}.case-v5 #planner .case-planner-day{min-height:70px;border-right:0;border-bottom:1px solid var(--case-border)}.case-v5 #planner .case-planner-day-head{font-size:.8rem}.case-v5 .v5-plan-add{flex:1}}
.case-v5 #caseTabs .nav-link.active{border-bottom:2px solid var(--case-primary)!important;color:var(--case-primary)!important}.case-v5 #caseTabs .nav-link i{display:none}
.case-v5 #planner-tab,.case-v5 #planner{display:none!important}
.case-v5 #case-left-column,.case-v5 #case-right-column{width:100%!important;max-width:100%!important;padding:0!important;flex:none!important}
.case-v5 .case-v4-details-columns{display:block!important;margin:0!important}.case-v5 #inner-center-col,.case-v5 #inner-left-col{padding:0!important;width:100%!important}
.case-v5 #case-left-column>.row,.case-v5 #inner-center-col>.row{margin:0!important;display:block!important}.case-v5 #inner-center-col>.row>.col-12{padding:0!important;margin:0!important}

View File

@ -0,0 +1,19 @@
.flatpickr-wrapper{display:block;width:100%}
.planning-picker.flatpickr-calendar{background:var(--bg-card,#fff);color:var(--text-primary,#243746);border:1px solid var(--frame-border,#dce4ea);border-radius:12px;box-shadow:0 10px 30px #102f4826;font-family:inherit;overflow:hidden}
.planning-picker.flatpickr-calendar.static.open{position:relative;top:auto;left:auto;margin-top:.5rem;box-shadow:0 3px 12px #102f4812}
.planning-picker .flatpickr-months{padding:.4rem 0;background:var(--accent-light,#f2f6f9)}
.planning-picker .flatpickr-month,.planning-picker .flatpickr-current-month .flatpickr-monthDropdown-months,.planning-picker span.flatpickr-weekday,.planning-picker .flatpickr-time input{background:transparent;color:var(--text-primary,#243746)}
.planning-picker .flatpickr-current-month{font-size:.95rem;font-weight:600;padding-top:.5rem}
.planning-picker .flatpickr-day{color:var(--text-primary,#243746);border-radius:8px}
.planning-picker .flatpickr-day:hover,.planning-picker .flatpickr-day:focus{background:var(--accent-light,#e7f0f5);border-color:var(--frame-border,#dce4ea)}
.planning-picker .flatpickr-day.selected,.planning-picker .flatpickr-day.selected:hover{background:var(--accent,#0f4c75);border-color:var(--accent,#0f4c75);color:white}
.planning-picker .flatpickr-day.today{border-color:var(--accent,#0f4c75)}
.planning-picker .flatpickr-day.prevMonthDay,.planning-picker .flatpickr-day.nextMonthDay{color:var(--text-secondary,#98a2b3);opacity:.65}
.planning-picker .flatpickr-prev-month,.planning-picker .flatpickr-next-month{top:.4rem;color:var(--text-primary,#243746);fill:currentColor}
.planning-picker .flatpickr-time{border-color:var(--frame-border,#dce4ea)}
.planning-picker .flatpickr-time input:hover,.planning-picker .flatpickr-time input:focus{background:var(--accent-light,#e7f0f5)}
.planning-picker-footer{display:flex;justify-content:space-between;gap:.5rem;padding:.6rem;border-top:1px solid var(--frame-border,#dce4ea)}
.planning-picker-footer button{border:1px solid var(--frame-border,#dce4ea);border-radius:7px;background:var(--bg-card,#fff);color:var(--accent,#0f4c75);padding:.3rem .7rem;font-size:.8rem;cursor:pointer}
.planning-picker-footer button:last-child{background:var(--accent,#0f4c75);border-color:var(--accent,#0f4c75);color:white;margin-left:auto}
.planning-date-input{cursor:pointer;padding-right:2.5rem;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='1.7' stroke-linecap='round'%3E%3Crect x='3' y='5' width='18' height='16' rx='3'/%3E%3Cpath d='M16 3v4M8 3v4M3 11h18'/%3E%3C/svg%3E");background-position:right .8rem center;background-repeat:no-repeat}

View File

@ -0,0 +1,51 @@
(() => {
'use strict';
const $=id=>document.getElementById(id),form=$('absenceFilters');
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const number=v=>Number(v).toLocaleString('da-DK',{maximumFractionDigits:2});
const hours=v=>number(v/60)+' t';
const date=v=>new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(v));
const day=v=>`${v.getFullYear()}-${String(v.getMonth()+1).padStart(2,'0')}-${String(v.getDate()).padStart(2,'0')}`;
let offset=0,requestVersion=0,filters=null,data=null;
const now=new Date();form.elements.start_date.value=day(new Date(now.getFullYear(),now.getMonth(),1));form.elements.end_date.value=day(new Date(now.getFullYear(),now.getMonth()+1,0));
window.BMCPlanningDates?.mount(form);
async function get(params) {
const response=await fetch('/api/v1/planner/absence-report?'+params,{headers:{Accept:'application/json'},credentials:'same-origin'});
const result=await response.json();if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Rapporten kunne ikke indlæses');return result;
}
function fail(err){$('absenceError').textContent=err.message;$('absenceError').hidden=false;}
async function load(newFilters=false) {
const version=++requestVersion;$('absenceError').hidden=true;$('absenceExport').disabled=true;$('absenceStatus').textContent='Henter fravær…';
if(newFilters){offset=0;filters=new URLSearchParams(new FormData(form));if(!filters.get('user_id'))filters.delete('user_id');filters.set('group_by','type');}
const params=new URLSearchParams(filters);params.set('offset',offset);params.set('limit',50);
try {
const result=await get(params);if(version!==requestVersion)return;data=result;
if(!form.elements.user_id.dataset.loaded){form.elements.user_id.insertAdjacentHTML('beforeend',result.employees.map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join(''));form.elements.user_id.dataset.loaded='1';}
$('absenceTotals').innerHTML=[['Arbejdsdage',number(result.totals.planned_days)],['Planlagte timer',hours(result.totals.planned_minutes)],['Faktisk registreret fravær',hours(result.totals.actual_minutes)]].map(([label,value])=>`<div class="col-sm-4"><div class="card h-100"><div class="card-body"><p class="small text-secondary mb-1">${label}</p><strong class="h4">${value}</strong></div></div></div>`).join('');
const values=r=>`<td>${r.norm_configured?number(r.planned_days):'Ukendt'}</td><td>${r.norm_configured?hours(r.planned_minutes):'Ukendt'}</td><td>${hours(r.actual_minutes)}</td>`;
$('absenceSummary').innerHTML=(result.type_summary||[]).map(group=>`<tr class="table-light"><th scope="row">${esc(group.kind_label)} <span class="small text-secondary fw-normal">· ${group.periods} perioder</span></th>${values(group)}</tr>`+group.employees.map(r=>`<tr><td class="ps-4">${esc(r.name)}${r.norm_configured?'':' <span class="small text-secondary">· arbejdstid mangler</span>'}</td>${values(r)}</tr>`).join('')).join('')||'<tr><td colspan="4" class="text-secondary p-3">Intet fravær i den valgte periode.</td></tr>';
let previousKind=null;
$('absenceRows').innerHTML=result.items.map(r=>{
const heading=previousKind!==r.kind?`<tr class="table-light"><th scope="rowgroup" colspan="8">${esc(r.kind_label)}</th></tr>`:'';previousKind=r.kind;
return heading+`<tr><td>${esc(r.name)}</td><td>${esc(r.kind_label)}</td><td>${date(r.starts_at)}</td><td>${date(r.ends_at)}</td><td>${r.norm_configured?number(r.planned_days):'Ukendt'}</td><td>${r.norm_configured?hours(r.planned_minutes):'Ukendt'}</td><td>${hours(r.actual_minutes)}</td><td><span class="badge ${r.registered?'bg-success':'bg-secondary'}">${r.registered?'Tid registreret':'Planlagt'}</span></td></tr>`;
}).join('')||'<tr><td colspan="8" class="text-secondary text-center p-4">Intet fravær i den valgte periode.</td></tr>';
$('absencePrev').disabled=offset===0;$('absenceNext').disabled=offset+result.items.length>=result.total;
$('absencePage').textContent=`${number(result.totals.planned_days)} arbejdsdage i alt · `+(result.total?`${offset+1}–${offset+result.items.length} af ${result.total} perioder`:'0 perioder');
$('absenceStatus').textContent=`${result.total} fraværsperioder. Planlagt og faktisk fravær vises separat.`;$('absenceExport').disabled=false;
}catch(err){if(version===requestVersion){data=null;$('absenceTotals').innerHTML='';$('absenceSummary').innerHTML='';$('absenceRows').innerHTML='';$('absencePrev').disabled=true;$('absenceNext').disabled=true;$('absenceStatus').textContent='';fail(err);}}
}
function csvCell(value) {
let text=String(value??'');if(/^[\s]*[=+\-@]/.test(text))text="'"+text;return '"'+text.replaceAll('"','""')+'"';
}
$('absenceExport').onclick=async()=>{
if(!data||!filters)return;$('absenceExport').disabled=true;
const exportFilters=new URLSearchParams(filters);
try {
let rows=[],page=0,total;
do {const params=new URLSearchParams(exportFilters);params.set('limit',500);params.set('offset',page);const result=await get(params);rows.push(...result.items);total=result.total;page+=result.items.length;if(!result.items.length&&page<total)throw new Error('Listen blev ændret. Hent rapporten igen.');}while(page<total);
const content=[['Fraværstype','Medarbejder','Fra','Til','Arbejdsdage','Planlagte timer','Faktisk registrerede timer','Status'],...rows.map(r=>[r.kind_label,r.name,date(r.starts_at),date(r.ends_at),r.norm_configured?number(r.planned_days):'Ukendt',r.norm_configured?number(r.planned_minutes/60):'Ukendt',number(r.actual_minutes/60),r.registered?'Tid registreret':'Planlagt'])].map(r=>r.map(csvCell).join(';')).join('\r\n');
const url=URL.createObjectURL(new Blob(['\uFEFF'+content],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=`fravaer-${exportFilters.get('start_date')}-${exportFilters.get('end_date')}.csv`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}catch(err){fail(err);}finally{$('absenceExport').disabled=!data;}
};
form.onsubmit=e=>{e.preventDefault();load(true);};$('absencePrev').onclick=()=>{offset=Math.max(0,offset-50);load();};$('absenceNext').onclick=()=>{offset+=50;load();};load(true);
})();

View File

@ -0,0 +1,20 @@
(() => {
'use strict';
const id=Number(location.pathname.match(/^\/sag\/(\d+)(?:\/|$)/)?.[1]),host=document.getElementById('planner');
if(!id||!host)return;
const card=document.createElement('section');card.className='card p-3 mb-3';
card.innerHTML=`<div class="d-flex align-items-center flex-wrap gap-2"><strong>Samlet planlægning</strong><select class="form-select form-select-sm" style="width:auto" aria-label="Sagsomfang"><option value="all">Hovedsag og undersager</option><option value="own">Kun denne sag</option><option value="children">Kun undersager</option></select><a class="btn btn-sm btn-outline-primary ms-auto" href="/planning?case_id=${id}">Redigér sagsplan</a></div><div class="small mt-2" role="status">Henter overblik…</div>`;
host.prepend(card);
const status=card.querySelector('[role=status]'),scope=card.querySelector('select');
const minutes=n=>n==null?'—':`${(Number(n)/60).toLocaleString('da-DK',{maximumFractionDigits:1})} t`;
const date=v=>v?new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(v)):'—';
let loading=false;
async function load(){
if(loading)return;loading=true;
try{const response=await fetch(`/api/v1/planner/cases/${id}/summary?scope=${scope.value}`);const value=await response.json();if(!response.ok)throw new Error(typeof value.detail==='string'?value.detail:'Overblikket kunne ikke indlæses');status.textContent=`Estimat: ${minutes(value.estimated_minutes)} · Planlagt (persontid): ${minutes(value.planned_person_minutes)} · Registreret: ${minutes(value.actual_minutes)} · Rest af estimat: ${minutes(value.remaining_minutes)} · Næste aktivitet: ${date(value.next_activity)} · Seneste deadline: ${date(value.latest_deadline)}${value.unestimated_cases?' · '+value.unestimated_cases+' sager uden estimat':''}`;}catch(e){status.textContent=e.message;}finally{loading=false;}
}
scope.onchange=load;new MutationObserver(()=>{if(host.classList.contains('active'))load();}).observe(host,{attributes:true,attributeFilter:['class']});
document.getElementById('planner-tab')?.addEventListener('click',load);
setInterval(()=>{if(!document.hidden&&host.offsetParent!==null)load();},30000);
if(host.classList.contains('active'))load();
})();

23
static/js/case-privacy.js Normal file
View File

@ -0,0 +1,23 @@
(() => {
'use strict';
const match=location.pathname.match(/^\/sag\/(\d+)(?:\/|$)/);
if(!match)return;
const id=Number(match[1]);
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function request(options) {
const r=await fetch(`/api/v1/sag/${id}/visibility`,options);
const value=await r.json();
if(!r.ok)throw new Error(typeof value.detail==='string'?value.detail:'Adgangen kunne ikke gemmes');
return value;
}
request().then(state=>{
if(!state.can_manage)return;
const button=document.createElement('button');button.className='btn btn-sm btn-outline-secondary';
button.textContent=state.restricted?'🔒 Fortrolig sag':'Fortrolighed';button.type='button';
button.style.cssText='position:fixed;right:1rem;bottom:5rem;z-index:1000';document.body.append(button);
const dialog=document.createElement('dialog');dialog.style.cssText='max-width:550px;width:95vw;border:2px solid var(--frame-border-strong);border-top:4px solid var(--accent);border-radius:var(--border-radius);box-shadow:var(--frame-shadow);padding:1rem;background:var(--bg-card);color:var(--text-primary)';
document.body.append(dialog);
const render=()=>{dialog.innerHTML=`<form><h2 class="h5">Fortrolighed</h2><p>Generelle sagsrettigheder gælder stadig. Administratorer har adgang til fortrolige sager.</p><label><input type="checkbox" name="restricted" ${state.restricted?'checked':''}> Begræns adgangen til udvalgte medarbejdere og grupper</label><label class="d-block mt-2">Medarbejdere<select name="users" class="form-select" multiple size="6">${state.users.map(u=>`<option value="${u.id}" ${state.user_ids.includes(u.id)?'selected':''}>${esc(u.name)}</option>`).join('')}</select></label><label class="d-block mt-2">Grupper<select name="groups" class="form-select" multiple size="5">${state.groups.map(g=>`<option value="${g.id}" ${state.group_ids.includes(g.id)?'selected':''}>${esc(g.name)}</option>`).join('')}</select></label><p class="text-danger" role="alert"></p><button class="btn btn-primary mt-3">Gem adgang</button> <button type="button" class="btn btn-light mt-3" data-close>Luk</button></form>`;dialog.querySelector('[data-close]').onclick=()=>dialog.close();dialog.querySelector('form').onsubmit=async e=>{e.preventDefault();const f=e.target;try{const result=await request({method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({restricted:f.elements.restricted.checked,version:state.version,user_ids:[...f.elements.users.selectedOptions].map(o=>Number(o.value)),group_ids:[...f.elements.groups.selectedOptions].map(o=>Number(o.value))})});state={...state,...result};button.textContent=state.restricted?'🔒 Fortrolig sag':'Fortrolighed';dialog.close();}catch(err){f.querySelector('[role=alert]').textContent=err.message;}};};
button.onclick=async()=>{try{state=await request();render();dialog.showModal();}catch(e){alert(e.message);}};
}).catch(()=>{});
})();

View File

@ -0,0 +1,83 @@
/* Resize case bookings through the existing versioned planner API. */
(() => {
'use strict';
const host = document.getElementById('casePlannerWeek');
if (!host) return;
let drag = null;
let saving = false;
const duration = button => (new Date(button.dataset.end) - new Date(button.dataset.start)) / 60000;
const endAt = (button, minutes) => new Date(new Date(button.dataset.start).getTime() + minutes * 60000);
function preview(button, minutes) {
button.closest('.case-planner-calendar-item').querySelector('strong').textContent =
`${new Date(button.dataset.start).toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}–${endAt(button, minutes).toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})} · ${minutes} min`;
const card = button.closest('.v5-time-event');
if (card && button.dataset.pixelsPerStep) card.style.height = `${Math.max(24, card.offsetHeight + (minutes - Number(button.dataset.previewMinutes || duration(button))) * 1.6)}px`;
button.dataset.previewMinutes = String(minutes);
}
async function save(button, minutes) {
if (minutes === duration(button)) return;
saving = true;
button.disabled = true;
const payload = {version: Number(button.dataset.version), ends_at: endAt(button, minutes).toISOString()};
try {
for (;;) {
const response = await fetch(`/api/v1/planner/allocations/${Number(button.dataset.allocationId)}`, {
method: 'PATCH', credentials: 'include',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
});
const body = await response.json();
if (response.ok) break;
const message = typeof body.detail === 'string' ? body.detail : body.detail?.message;
if (response.status === 409 && body.detail?.conflict_status && !payload.confirm_conflicts &&
window.confirm(`${message}. Vil du gemme alligevel?`)) {
payload.confirm_conflicts = true;
continue;
}
throw new Error(message || 'Kunne ikke ændre varigheden');
}
window.showCaseFeedback('Aftalens varighed er gemt', 'success');
} catch (error) {
window.showCaseFeedback(error.message);
} finally {
await window.loadEmbeddedCasePlanner(true);
saving = false;
button.disabled = false;
}
}
host.addEventListener('click', event => {
if (event.target.closest('.case-slot-resize')) event.stopPropagation();
});
host.addEventListener('pointerdown', event => {
const button = event.target.closest('.case-slot-resize');
if (!button || saving || drag || (event.button !== 0 && event.pointerType !== 'touch')) return;
event.preventDefault();
event.stopPropagation();
drag = {button, y: event.clientY, original: duration(button), minutes: duration(button), pointer: event.pointerId};
button.setPointerCapture(event.pointerId);
button.classList.add('is-resizing');
});
host.addEventListener('pointermove', event => {
if (!drag || drag.pointer !== event.pointerId) return;
drag.minutes = Math.max(15, drag.original + Math.round((event.clientY - drag.y) / (Number(drag.button.dataset.pixelsPerStep) || 12)) * 15);
preview(drag.button, drag.minutes);
});
function finish(event, cancel) {
if (!drag || drag.pointer !== event.pointerId) return;
const current = drag;
drag = null;
current.button.classList.remove('is-resizing');
if (current.button.hasPointerCapture(event.pointerId)) current.button.releasePointerCapture(event.pointerId);
if (cancel) preview(current.button, current.original);
else void save(current.button, current.minutes);
}
host.addEventListener('pointerup', event => finish(event, false));
host.addEventListener('pointercancel', event => finish(event, true));
host.addEventListener('lostpointercapture', event => finish(event, true));
host.addEventListener('keydown', event => {
const button = event.target.closest('.case-slot-resize');
if (!button || saving || drag || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
void save(button, Math.max(15, duration(button) + (event.key === 'ArrowDown' ? 15 : -15)));
});
})();

View File

@ -170,14 +170,81 @@
const pane = document.querySelector(button.getAttribute('data-bs-target'));
if (pane && pane.parentElement !== byId('caseTabsContent')) byId('caseTabsContent').append(pane);
});
const planner = byId('planner');
if (planner) { planner.hidden = true; planner.style.setProperty('display', 'none', 'important'); }
byId('planner-tab')?.closest('.nav-item')?.remove();
// Keep planner markup for existing dialogs, but never expose its pane in V5.
if (new URLSearchParams(location.search).get('tab') === 'planner') {
const url = new URL(location.href);
url.searchParams.set('tab', 'details');
history.replaceState(null, '', url);
// Keep the case plan primary; existing subcases remain available on demand.
const subcaseForm = byId('casePlannerSubcaseForm');
const subcaseCard = subcaseForm?.closest('.case-planner-card');
const plannerBody = byId('casePlannerAllocationForm')?.closest('.case-planner-card-body');
if (subcaseCard && plannerBody) {
const subcases = document.createElement('details');
subcases.className = 'v5-planner-subcases mt-3';
const summary = document.createElement('summary');
summary.textContent = 'Vis undersagers planlægning';
subcases.append(summary);
const calendar = byId('casePlannerSubcaseWeek')?.closest('.case-planner-calendar');
if (calendar) subcases.append(calendar);
const list = byId('casePlannerSubcaseList');
if (list) subcases.append(list);
plannerBody.append(subcases);
subcaseCard.remove();
}
if (plannerBody) {
// The existing form still supplies defaults to the shared booking dialog.
byId('casePlannerAllocationForm').hidden = true;
const header = plannerBody.previousElementSibling;
header.querySelector('h5').textContent = 'Hvornår skal sagen løses?';
header.querySelector('.small').textContent = 'Klik på en dag i kalenderen for at planlægge arbejdet.';
const refresh = header.querySelector('button');
refresh.title = 'Opdatér planen';
refresh.setAttribute('aria-label', 'Opdatér planen');
const add = document.createElement('button');
add.type = 'button';
add.className = 'btn btn-primary v5-plan-add';
add.textContent = '+ Planlæg tid';
add.addEventListener('click', () => {
const today = new Date();
const localDate = new Date(today.getTime() - today.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
window.openEmbeddedPlannerSlot(localDate);
});
header.insertBefore(add, refresh);
const metrics = byId('casePlannerEstimated').closest('.row');
const overview = document.createElement('details');
overview.className = 'v5-planner-overview mt-3';
const overviewTitle = document.createElement('summary');
overviewTitle.textContent = 'Vis forventet og planlagt tid';
overview.append(overviewTitle, metrics);
plannerBody.append(overview);
const calendar = byId('casePlannerWeek').closest('.case-planner-calendar');
calendar.querySelector('.case-planner-calendar-toolbar > span').remove();
calendar.querySelector('button:nth-child(2)').textContent = 'Denne uge';
const listTitle = document.createElement('h6');
listTitle.className = 'mt-4 mb-0';
listTitle.textContent = 'Aftaler på sagen';
byId('casePlannerAllocationList').before(listTitle);
byId('casePlannerSlotModalTitle').textContent = 'Planlæg tid på sagen';
byId('casePlannerSlotSaveBtn').textContent = 'Gem aftale';
const duration = byId('casePlannerSlotMinutes');
duration.closest('.col-12').querySelector('label').textContent = 'Hvor lang tid?';
const presets = document.createElement('div');
presets.className = 'd-flex gap-2 mt-2';
for (const [minutes, label] of [[30, '30 min'], [60, '1 time'], [120, '2 timer']]) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-outline-secondary';
button.textContent = label;
button.addEventListener('click', () => { duration.value = String(minutes); });
presets.append(button);
}
duration.closest('.col-12').append(presets);
const note = byId('casePlannerSlotNote').closest('.col-12');
const noteDetails = document.createElement('details');
noteDetails.className = 'col-12';
const noteTitle = document.createElement('summary');
noteTitle.textContent = 'Tilføj en note (valgfrit)';
note.before(noteDetails);
noteDetails.append(noteTitle, note);
const modal = byId('casePlannerSlotModal');
modal.querySelector('.alert').textContent = 'Aftalen reserverer tid i kalenderen. Udført arbejde registreres separat.';
modal.querySelector('.modal-body > .small').textContent = 'Du får besked, hvis tiden overlapper en anden aftale.';
}
const details = byId('details');
const left = byId('case-left-column');

View File

@ -0,0 +1,59 @@
/* Case-local time grid; the shared planner remains responsible for loading. */
(() => {
'use strict';
window.renderCaseWeekCalendar = function(host, anchor, items) {
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'}[c]));
const key = date => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
const time = date => date.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'});
const monday = new Date(anchor);
monday.setHours(0,0,0,0);
const days = [];
let firstHour = 7, lastHour = 17;
for (let index = 0; index < 7; index++) {
const date = new Date(monday); date.setDate(date.getDate()+index);
const next = new Date(date); next.setDate(next.getDate()+1);
const segments = items.filter(item => new Date(item.starts_at) < next && new Date(item.ends_at) > date).map(item => {
const start = new Date(Math.max(new Date(item.starts_at).getTime(), date.getTime()));
const end = new Date(Math.min(new Date(item.ends_at).getTime(), next.getTime()));
const from = start.getHours()*60+start.getMinutes();
const to = end.getTime() === next.getTime() ? 1440 : end.getHours()*60+end.getMinutes();
firstHour = Math.min(firstHour, Math.floor(from/60));
lastHour = Math.max(lastHour, Math.ceil(to/60));
return {item, start, end, from, to};
}).sort((a,b) => a.from-b.from);
if (index < 5 || segments.length) days.push({date, segments});
}
const height = (lastHour-firstHour)*96;
host.classList.add('v5-time-grid');
host.style.setProperty('--week-days', days.length);
host.dataset.firstHour = String(firstHour);
host.dataset.lastHour = String(lastHour);
host.innerHTML = '<div class="v5-time-corner">Kl.</div>' + days.map(({date}) => `<div class="v5-time-heading ${key(date) === key(new Date()) ? 'is-today' : ''}">${esc(date.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'}))}</div>`).join('') +
`<div class="v5-time-labels" style="height:${height}px">${Array.from({length:lastHour-firstHour+1},(_,i)=>`<span style="top:${i*96}px">${String(firstHour+i).padStart(2,'0')}:00</span>`).join('')}</div>` +
days.map(({date,segments}) => {
const lanes = [];
for (const segment of segments) {
let lane = lanes.findIndex(end => end <= segment.from);
if (lane < 0) lane = lanes.length;
lanes[lane] = segment.to; segment.lane = lane;
}
return `<div class="v5-time-day" data-date="${key(date)}" style="height:${height}px">${segments.map(({item,start,end,from,to,lane}) => {
const canResize = !item.locked && new Date(item.ends_at).getTime() === end.getTime();
return `<div class="case-planner-calendar-item v5-time-event" style="top:${(from-firstHour*60)*1.6}px;height:${(to-from)*1.6}px;left:calc(${lane*100/lanes.length}% + 2px);width:calc(${100/lanes.length}% - 4px)"><strong>${time(start)}–${time(end)}</strong><div>${esc(item.user_name || 'Ukendt')}</div>${item.note ? `<div>${esc(item.note)}</div>` : ''}${item.conflict_status && item.conflict_status !== 'none' ? '<span class="badge text-bg-warning">Overlap</span>' : ''}${canResize ? `<button type="button" class="case-slot-resize" data-allocation-id="${Number(item.id)}" data-version="${Number(item.version)}" data-start="${esc(item.starts_at)}" data-end="${esc(item.ends_at)}" data-pixels-per-step="24" aria-label="Ændr varighed: træk eller brug piletaster" title="Træk for at ændre varighed">━</button>` : ''}</div>`;
}).join('')}</div>`;
}).join('');
const friday = days[days.length-1].date;
document.getElementById('casePlannerWeekLabel').textContent = `${monday.toLocaleDateString('da-DK',{day:'numeric',month:'short'})} – ${friday.toLocaleDateString('da-DK',{day:'numeric',month:'short',year:'numeric'})}`;
if (!host.dataset.timeGridBound) {
host.dataset.timeGridBound = 'true';
host.addEventListener('click', event => {
if (event.target.closest('.v5-time-event')) return;
const day = event.target.closest('.v5-time-day');
if (!day) return;
const minutes = Math.max(0, Math.min(1425, Number(host.dataset.firstHour)*60 + Math.floor((event.clientY-day.getBoundingClientRect().top)/24)*15));
window.openEmbeddedPlannerSlot(day.dataset.date);
document.getElementById('casePlannerSlotTime').value = `${String(Math.floor(minutes/60)).padStart(2,'0')}:${String(minutes%60).padStart(2,'0')}`;
});
}
};
})();

View File

@ -0,0 +1,197 @@
/* All intake sources prefill the same reviewed form; no automatic hardware writes. */
function newHardwareIntakeRequestId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, token => {
const value = Math.floor(Math.random() * 16);
return (token === 'x' ? value : (value & 3) | 8).toString(16);
});
}
const hardwareIntake = {mode: 'manual', requestId: newHardwareIntakeRequestId(), preview: null, line: null, code: null, productId: null, products: [], generation: 0};
const intakeEl = id => document.getElementById(id);
const intakeEscape = value => String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
const intakeMessage = (message, error = false) => {
intakeEl('intake-message').textContent = message;
intakeEl('intake-message').className = error ? 'small text-danger' : 'small text-muted';
};
async function intakeFetch(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
let message = 'Kunne ikke hente oplysninger';
try { const error = await response.json(); message = typeof error.detail === 'string' ? error.detail : message; } catch {}
throw new Error(message);
}
return response.json();
}
function setHardwareIntakeMode(mode) {
hardwareIntake.inventory = null;
intakeEl('intake-inventory-panel').hidden = true;
hardwareIntake.mode = mode;
hardwareIntake.preview = null;
hardwareIntake.line = null;
hardwareIntake.code = null;
hardwareIntake.productId = null;
hardwareIntake.generation++;
for (const name of ['manual', 'invoice', 'barcode']) {
intakeEl(`intake-mode-${name}`).className = name === mode ? 'btn btn-primary' : 'btn btn-outline-primary';
}
intakeEl('intake-invoice-panel').hidden = mode !== 'invoice';
intakeEl('intake-barcode-panel').hidden = mode !== 'barcode';
intakeEl('intake-invoice-lines').innerHTML = '';
intakeMessage(mode === 'manual' ? 'Udfyld formularen manuelt. Intet oprettes, før du gemmer.' : 'Vælg et grundlag. Kontrollér derefter hardwaretype, ejer og asset-type i formularen.');
if (mode === 'invoice') findHardwareInvoiceSources();
if (mode === 'barcode') intakeEl('intake-code').focus();
}
async function findHardwareInvoiceSources() {
const version = ++hardwareIntake.generation;
intakeMessage('Henter leverandørfakturaer…');
try {
const data = await intakeFetch(`/api/v1/hardware-intake/sources?q=${encodeURIComponent(intakeEl('intake-invoice-search').value)}`);
if (version !== hardwareIntake.generation) return;
intakeEl('intake-invoice-results').innerHTML = data.sources.length ? data.sources.map(source => `<button type="button" class="list-group-item list-group-item-action" onclick="previewHardwareInvoice('${source.kind === 'invoice' ? 'invoice' : 'file'}',${Number(source.id)})"><span class="badge bg-light text-dark me-2">${source.kind === 'invoice' ? 'Faktura' : 'Fil'}</span>${intakeEscape(source.label)}</button>`).join('') : '<div class="text-muted p-2">Ingen fakturaer fundet. Upload en faktura ovenfor.</div>';
intakeMessage('Vælg en faktura eller upload en PDF/billedfil.');
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message, true); }
}
async function previewHardwareInvoice(kind, id) {
const version = ++hardwareIntake.generation;
hardwareIntake.preview = null; hardwareIntake.line = null;
intakeMessage('Læser fakturalinjer. Tekstgenkendelse kan tage lidt tid…');
try {
const data = await intakeFetch('/api/v1/hardware-intake/preview', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind,id})});
if (version === hardwareIntake.generation) showHardwareInvoicePreview(data);
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message, true); }
}
async function uploadHardwareInvoice() {
const file = intakeEl('intake-upload').files[0];
if (!file) return;
const version = ++hardwareIntake.generation;
hardwareIntake.preview = null; hardwareIntake.line = null;
const body = new FormData(); body.append('file', file);
intakeMessage('Uploader og læser fakturaen. Vent på linjerne, før du vælger hardware…');
try {
const data = await intakeFetch('/api/v1/hardware-intake/upload', {method:'POST', body});
if (version === hardwareIntake.generation) showHardwareInvoicePreview(data);
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message, true); }
}
function showHardwareInvoicePreview(data) {
hardwareIntake.preview = data;
const label = [data.header.vendor_name, data.header.invoice_number || data.header.filename].filter(Boolean).join(' · ');
intakeEl('intake-invoice-lines').innerHTML = `<h3 class="h6">${intakeEscape(label || 'Fakturalinjer')}</h3><div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Vare</th><th>Antal</th><th>Enhedspris ekskl. moms</th><th></th></tr></thead><tbody>${data.lines.map((line,index) => `<tr><td>${intakeEscape(line.description)}<div class="small text-muted">${intakeEscape(line.sku || '')}</div></td><td>${intakeEscape(line.quantity ?? 'Ukendt')}${data.created_units?.[line.key]?.length ? `<div class="small text-muted">${data.created_units[line.key].map(id => `<a href="/hardware/${Number(id)}" target="_blank" rel="noopener">#${Number(id)}</a>`).join(', ')} allerede oprettet</div>` : ''}</td><td>${intakeEscape(line.unit_price ?? 'Ukendt')} ${intakeEscape(data.header.currency || '')}</td><td><button type="button" class="btn btn-sm btn-outline-primary" onclick="chooseHardwareInvoiceLine(${index})">Brug linje</button></td></tr>`).join('')}</tbody></table></div>`;
intakeMessage(data.lines.length ? 'Vælg kun linjer med fysisk hardware. Kontrollér udlæste oplysninger før oprettelse.' : 'Ingen varelinjer kunne udlæses. Vælg en anden faktura eller opret manuelt.', !data.lines.length);
}
function chooseHardwareInvoiceLine(index) {
const line = hardwareIntake.preview?.lines[index];
const count = Number(line?.quantity);
const remaining = count - (hardwareIntake.preview?.created_units?.[line?.key]?.length || 0);
if (!line || !Number.isInteger(count) || count < 1) { intakeMessage('Linjen mangler et positivt helt antal. Afklar fakturagrundlaget før oprettelse.', true); return; }
if (remaining < 1) { intakeMessage('Alle enheder på linjen er allerede oprettet. Se de tilknyttede hardwareposter.', true); return; }
hardwareIntake.line = line;
intakeEl('model').value = line.description;
intakeEl('brand').value = '';
intakeEl('intake-count').value = Math.min(remaining,100);
intakeEl('intake-unit-cost').value = line.unit_price ?? '';
intakeEl('intake-currency').value = hardwareIntake.preview.header.currency || 'DKK';
updateHardwareIntakeQuantity();
intakeMessage(`Valgt: ${line.description}. Kontrollér antal, serienumre og ejer i formularen nedenfor. Fakturaen bogføres ikke af hardwareoprettelsen.`);
intakeEl('asset_type').focus();
}
async function lookupHardwareCode() {
const code = intakeEl('intake-code').value.trim();
if (!code) { intakeMessage('Scan eller indtast en kode først.', true); return; }
const version = ++hardwareIntake.generation;
hardwareIntake.code = null; hardwareIntake.productId = null;
intakeMessage('Slår koden op i varekartoteket…');
try {
const local = await intakeFetch(`/api/v1/products?q=${encodeURIComponent(code)}`);
let products = (Array.isArray(local) ? local : local.products || []).map(product => ({...product, intakeLocal:true}));
if (!products.length) {
intakeMessage('Ingen lokal vare fundet. Søger via API Gateway…');
const external = await intakeFetch(`/api/v1/products/apigateway/search?q=${encodeURIComponent(code)}&per_page=10`);
products = external.products || external.items || [];
}
if (version !== hardwareIntake.generation) return;
hardwareIntake.products = products.slice(0,15);
hardwareIntake.lookupCode = code;
intakeEl('intake-product-results').innerHTML = hardwareIntake.products.length ? hardwareIntake.products.map((product,index) => `<button type="button" class="list-group-item list-group-item-action" onclick="chooseHardwareProduct(${index})"><strong>${intakeEscape(product.name || product.product_name || product.model || '(Vare uden navn)')}</strong><div class="small text-muted">${intakeEscape(product.ean || '')} · ${intakeEscape(product.sku_internal || product.sku || '')}</div></button>`).join('') : '<div class="text-muted p-2">Ingen vare fundet. Prøv et varenummer, eller vælg 100 % manuelt.</div>';
intakeMessage('Vælg den rigtige vare. Et EAN identificerer varetypen; hver maskine får sit eget serienummer.');
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message + ' Du kan stadig vælge 100 % manuelt.', true); }
}
function chooseHardwareProduct(index) {
const product = hardwareIntake.products[index];
if (!product) return;
hardwareIntake.code = hardwareIntake.lookupCode;
hardwareIntake.productId = product.intakeLocal ? product.id : null;
intakeEl('model').value = product.name || product.product_name || product.model || '';
const brand = product.manufacturer || product.brand || '';
intakeEl('brand').value = typeof brand === 'string' ? brand : brand.name || '';
intakeEl('intake-unit-cost').value = product.supplier_price ?? product.cost_price ?? product.price ?? '';
intakeEl('intake-currency').value = product.cost_currency || product.supplier_currency || product.currency || 'DKK';
intakeMessage(`Valgt vare: ${intakeEl('model').value}. Kontrollér oplysningerne og vælg hardwaretype og ejer før gemning.`);
intakeEl('asset_type').focus();
}
function updateHardwareIntakeQuantity() {
const count = Number(intakeEl('intake-count').value);
intakeEl('serial_number').closest('.form-group').hidden = count > 1;
intakeEl('intake-serials-group').hidden = count <= 1;
intakeEl('intake-save').textContent = count > 1 ? `Gem ${count} hardwareenheder` : 'Gem hardware';
}
function hardwareIntakePayload(hardware) {
if (hardwareIntake.mode === 'invoice' && !hardwareIntake.line) throw new Error('Vælg en fakturalinje først');
if (hardwareIntake.mode === 'barcode' && !hardwareIntake.code) throw new Error('Slå en kode op, og vælg en vare først');
const count = Number(intakeEl('intake-count').value);
const serialText = intakeEl('intake-serials').value.trim();
if (hardwareIntake.inventory) {
if (count !== 1) throw new Error('En inventory-maskine kan kun oprette ét asset');
hardware.hardware_specs = {...hardware.hardware_specs, ...hardwareIntake.inventory.hardware_specs};
}
return {request_id:hardwareIntake.requestId, mode:hardwareIntake.mode, hardware, count,
serial_numbers:count > 1 && serialText ? serialText.split(/\r?\n/) : [],
preview_id:hardwareIntake.preview?.preview_id || null, line_key:hardwareIntake.line?.key || null,
code:hardwareIntake.code, product_id:hardwareIntake.productId,
approved_unit_cost:intakeEl('intake-unit-cost').value || null, currency:intakeEl('intake-currency').value};
}
function openHardwareInventory() {
setHardwareIntakeMode('manual');
intakeEl('intake-inventory-panel').hidden = false;
findHardwareInventory();
}
async function findHardwareInventory(page = 1) {
const version = ++hardwareIntake.generation;
intakeMessage('Henter PC / Mac inventory…');
try {
const data = await intakeFetch(`/api/v1/hardware-intake/inventory?q=${encodeURIComponent(intakeEl('intake-inventory-search').value)}&by=${encodeURIComponent(intakeEl('intake-inventory-by').value || 'name')}&page=${page}`);
if (version !== hardwareIntake.generation) return;
hardwareIntake.inventoryItems = data.items;
intakeEl('intake-inventory-results').innerHTML = data.items.map((item,index) => `<button type="button" class="list-group-item list-group-item-action" onclick="chooseHardwareInventory(${index})"><strong>${intakeEscape(item.name)}</strong><div>${intakeEscape([item.brand,item.model,item.serial_number].filter(Boolean).join(' · '))}</div><div class="small text-muted">${intakeEscape([item.os,item.cpu,item.ram_gb != null ? item.ram_gb + ' GB RAM' : '',item.last_seen].filter(Boolean).join(' · '))}</div></button>`).join('') || '<div class="text-muted">Ingen maskiner fundet.</div>';
if (page > 1) intakeEl('intake-inventory-results').innerHTML += `<button type="button" class="btn btn-secondary" onclick="findHardwareInventory(${page-1})">Forrige</button>`;
if (data.pagination?.total_pages > page) intakeEl('intake-inventory-results').innerHTML += `<button type="button" class="btn btn-secondary" onclick="findHardwareInventory(${page+1})">Næste</button>`;
intakeMessage(hardwareIntake.pairHardwareId ? 'Klik på en maskine for at parre den med dette asset. Eksisterende felter bevares.' : 'Vælg en maskine. Kontrollér oplysninger, hardwaretype og ejer før gemning.');
} catch (error) { if (version === hardwareIntake.generation) intakeMessage(error.message,true); }
}
async function chooseHardwareInventory(index) {
const item = hardwareIntake.inventoryItems[index];
if (!item) return;
if (hardwareIntake.pairHardwareId) {
if (hardwareIntake.pairing) return;
hardwareIntake.pairing = true;
try {
await intakeFetch(`/api/v1/hardware-intake/inventory/${hardwareIntake.pairHardwareId}/pair`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({inventory_id:item.id})});
window.location.reload();
} catch (error) { intakeMessage(error.message, true); }
finally { hardwareIntake.pairing = false; }
return;
}
hardwareIntake.inventory = item;
for (const key of ['brand','model','serial_number']) intakeEl(key).value = item[key] || '';
intakeEl('asset_type').value = 'pc';
intakeEl('intake-count').value = 1;
updateHardwareIntakeQuantity();
intakeMessage(`Valgt: ${item.name}. CPU, RAM, OS og øvrige inventory-oplysninger gemmes med asset. Kontrollér ejer og hardwaretype før gemning.`);
}
async function unpairHardwareInventory() {
try {
await intakeFetch(`/api/v1/hardware-intake/inventory/${hardwareIntake.pairHardwareId}/pair`, {method:'DELETE'});
window.location.reload();
} catch (error) { intakeMessage(error.message, true); }
}

View File

@ -8,8 +8,8 @@
const fmtMin=m=>m?`${Math.floor(m/60)}t${m%60?' '+m%60+'m':''}`:'0t';
const api=async(url,opt={})=>{const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opt});let b;try{b=await r.json()}catch{b={detail:'Ukendt serverfejl'}}if(!r.ok){const e=new Error(typeof b.detail==='string'?b.detail:b.detail?.message||'Handlingen mislykkedes');e.status=r.status;e.body=b;throw e}return b};
function range(){let a=new Date(state.anchor),b;if(state.view==='day'){a.setHours(0,0,0,0);b=new Date(a);b.setDate(b.getDate()+1)}else if(state.view==='week'){a.setHours(0,0,0,0);a.setDate(a.getDate()-((a.getDay()+6)%7));b=new Date(a);b.setDate(b.getDate()+7)}else{a=new Date(a.getFullYear(),a.getMonth(),1);b=new Date(a.getFullYear(),a.getMonth()+1,1)}return[a,b]}
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:state.meta.users.map(u=>u.id)}
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:Array.from($('#employeeFilter').selectedOptions,o=>Number(o.value))}
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}for(const u of state.meta.users){$('#employeeFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}" selected>${esc(u.name)}</option>`)}if(new URLSearchParams(location.search).has('embedded')){state.scope='team';document.querySelectorAll('[data-scope]').forEach(b=>b.classList.toggle('active',b.dataset.scope==='team'))}bind();$('#employeeFilter').onchange=()=>{state.scope='team';document.querySelectorAll('[data-scope]').forEach(b=>b.classList.toggle('active',b.dataset.scope==='team'));loadPeriod()};await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial','overdue'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{const current=await api('/api/v1/planner/cases?'+p);let overdueItems=[];try{overdueItems=(await api('/api/v1/planner/cases?planning=overdue&sort=priority')).items||[]}catch(error){console.warn('Kunne ikke hente overskredne planer',error)}state.cases=current.items;state.overdueCases=overdueItems;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const currentType=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===currentType?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
@ -28,12 +28,15 @@
function hourMarks(){return Array.from({length:12},(_,i)=>`<span class="hour-mark" style="top:${i*60}px">${pad(i+7)}:00</span>`).join('')}
function registeredHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="registered-time ${!x.sag_id?'quick-registered':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}"><div class="time"><i class="bi bi-stopwatch me-1"></i>${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())} · registreret</div><strong>${x.sag_id?'#'+x.sag_id+' ':'Kun tid · '}${esc(x.case_title)}</strong><div>${fmtMin(x.minutes)} brugt</div></div>`}
function untimedHtml(items){return items.length?`<div class="untimed-registers">${items.map(x=>`<div class="untimed-register" title="${esc(x.case_title)}"><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · #${x.sag_id} · ${fmtMin(x.minutes)} registreret</div>`).join('')}</div>`:''}
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 07–18</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),standalone=(state.data.standalone_plans||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout([...items,...standalone.map(x=>({...x,id:`quick:${x.id}`}))],registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${standalone.map(x=>`<a class="allocation quick-planned" style="${blockStyle(x.starts_at,x.ends_at,lanes.get(`p:quick:${x.id}`))}" href="/sag/new?quick_plan=${Number(x.id)}"><div class="time">${new Date(x.starts_at).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'})} · Kun tid</div><strong>${esc(x.description)}</strong><div>${esc(x.customer_name)} · Registrér →</div></a>`).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">#${x.sag_id} ${esc(x.case_title)}</div>`).join('')}${(state.data.time_entries||[]).filter(x=>!x.sag_id&&dateKey(new Date(x.starts_at))===key).map(x=>`<div class="allocation quick-registered">Brugt tid · ${esc(x.description)} · ${fmtMin(x.minutes)}</div>`).join('')}${(state.data.standalone_plans||[]).filter(x=>dateKey(new Date(x.starts_at))===key).map(x=>`<a class="allocation quick-planned" href="/sag/new?quick_plan=${Number(x.id)}">Kun tid · ${esc(x.description)}</a>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
function bindCalendar(){document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
function absencesOnDay(day,userId=null){const start=new Date(day);start.setHours(0,0,0,0);const end=new Date(start);end.setDate(end.getDate()+1);return(state.data.absences||[]).filter(x=>(userId===null||x.user_id===userId)&&new Date(x.starts_at)<end&&new Date(x.ends_at)>start)}
function absenceHtml(item,day){const start=new Date(day),end=new Date(day);start.setHours(7,0,0,0);end.setHours(18,0,0,0);const a=new Date(Math.max(start,new Date(item.starts_at))),b=new Date(Math.min(end,new Date(item.ends_at)));if(b<=a)return '';const label=item.kind_label||'Fravær';return `<div class="planner-absence" style="${blockStyle(a,b)}${a.getHours()===7&&a.getMinutes()===0?'padding-top:32px;':''}" title="${esc(label)}"><strong><i class="bi bi-calendar-minus" aria-hidden="true"></i> ${esc(label)}</strong></div>`}
function absenceOverview(start,end){const rows=(state.data.absences||[]).filter(x=>selectedUsers().includes(x.user_id)&&new Date(x.starts_at)<end&&new Date(x.ends_at)>start),format=d=>d.toLocaleDateString('da-DK',{day:'numeric',month:'short'});return `<section class="planner-absence-overview" aria-label="Fravær i perioden"><strong><i class="bi bi-calendar-minus" aria-hidden="true"></i> Fravær i perioden</strong><div>${rows.length?rows.map(x=>{const a=new Date(Math.max(start,new Date(x.starts_at))),b=new Date(Math.min(end,new Date(x.ends_at))-1);return `<button type="button" class="planner-absence-link" data-absence-user="${x.user_id}" data-absence-date="${dateKey(a)}" title="Vis medarbejderens kalender"><strong>${esc(state.meta.users.find(u=>u.id===x.user_id)?.name||'')}</strong> · ${esc(x.kind_label||'Fravær')} · ${format(a)}${dateKey(a)===dateKey(b)?'':' – '+format(b)}</button>`}).join(''):'<span class="small text-muted">Intet fravær for de valgte medarbejdere i perioden.</span>'}</div></section>`}
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+absenceOverview(a,b)+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head" data-user="${u.id}">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 07–18</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),standalone=(state.data.standalone_plans||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout([...items,...standalone.map(x=>({...x,id:`quick:${x.id}`}))],registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${absencesOnDay(d,u.id).map(x=>absenceHtml(x,d)).join('')}${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${standalone.map(x=>`<a class="allocation quick-planned" style="${blockStyle(x.starts_at,x.ends_at,lanes.get(`p:quick:${x.id}`))}" href="/sag/new?quick_plan=${Number(x.id)}"><div class="time">${new Date(x.starts_at).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'})} · Kun tid</div><strong>${esc(x.description)}</strong><div>${esc(x.customer_name)} · Registrér →</div></a>`).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>${x.sag_id?'#'+x.sag_id+' ':''}${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+absenceOverview(new Date(a.getFullYear(),a.getMonth(),1),new Date(a.getFullYear(),a.getMonth()+1,1))+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${absencesOnDay(d).filter(x=>selectedUsers().includes(x.user_id)).map(x=>`<div class="planner-absence-month"><i class="bi bi-calendar-minus" aria-hidden="true"></i> ${esc(x.kind_label||'Fravær')} · ${esc(state.meta.users.find(u=>u.id===x.user_id)?.name||'')}</div>`).join('')}${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">${x.sag_id?'#'+x.sag_id+' ':''}${esc(x.case_title)}</div>`).join('')}${(state.data.time_entries||[]).filter(x=>!x.sag_id&&dateKey(new Date(x.starts_at))===key).map(x=>`<div class="allocation quick-registered">Brugt tid · ${esc(x.description)} · ${fmtMin(x.minutes)}</div>`).join('')}${(state.data.standalone_plans||[]).filter(x=>dateKey(new Date(x.starts_at))===key).map(x=>`<a class="allocation quick-planned" href="/sag/new?quick_plan=${Number(x.id)}">Kun tid · ${esc(x.description)}</a>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
function bindCalendar(){document.querySelectorAll('[data-absence-user]').forEach(button=>button.onclick=()=>{const target=document.querySelector(`.employee-head[data-user="${Number(button.dataset.absenceUser)}"]`)||document.querySelector(`.month-day[data-date="${button.dataset.absenceDate}"]`);if(target){const timeline=$('#timeline'),top=target.getBoundingClientRect().top-timeline.getBoundingClientRect().top+timeline.scrollTop;timeline.scrollTo({top:Math.max(0,top-48),behavior:'smooth'});}});document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
async function drop(e,cell){e.preventDefault();const caseId=e.dataTransfer.getData('application/x-case'),allocId=e.dataTransfer.getData('application/x-allocation');const rect=cell.getBoundingClientRect(),ratio=Math.max(0,Math.min(1,(e.clientY-rect.top)/rect.height)),minutes=Math.round((7*60+ratio*11*60)/15)*15;const start=new Date(cell.dataset.date+'T00:00');start.setMinutes(minutes);if(caseId){const c=state.cases.find(x=>x.id===Number(caseId)),defaultMins=Math.max(15,Number(state.meta.settings.default_allocation_minutes)||60),remainingMins=Math.max(15,Number(c?.remaining_minutes)||defaultMins),mins=Math.min(remainingMins,defaultMins);await mutate('/api/v1/planner/allocations','POST',{sag_id:Number(caseId),user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+mins*60000).toISOString(),source:'manual'})}else if(allocId){const x=state.data.allocations.find(a=>a.id===Number(allocId)),dur=new Date(x.ends_at)-new Date(x.starts_at);await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+dur).toISOString(),version:x.version})}}
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable);$('#dialogTitle').textContent=`#${x.sag_id} · ${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogWarning').classList.add('d-none');$('#allocationDialog').showModal()}
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;window.BMCPlanningDates?.reset($('#allocationDialog'));state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable||!x.sag_id);$('#dialogTitle').textContent=`${x.sag_id?'#'+x.sag_id+' · ':''}${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogCaseLink').classList.toggle('d-none',!x.sag_id);$('#dialogWarning').classList.add('d-none');window.BMCPlanningDates?.mount($('#allocationDialog'));$('#allocationDialog').showModal()}
async function saveSelected(){const x=state.selected;await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{starts_at:new Date($('#dialogStart').value).toISOString(),ends_at:new Date($('#dialogEnd').value).toISOString(),note:$('#dialogNote').value,locked:$('#dialogLocked').checked,version:x.version},true)}
async function deleteSelected(){const x=state.selected;if(!confirm('Slet denne tidsblok? Sagen bevares.'))return;try{const result=await api(`/api/v1/planner/allocations/${x.id}?version=${x.version}`,{method:'DELETE'});$('#allocationDialog').close();const a=result.allocation;toast('Tidsblokken blev slettet','Fortryd',()=>undoMutation(a.id,a.version));await refresh()}catch(e){toast(e.message)}}
async function copySelected(){const x=state.selected,start=new Date(x.starts_at);start.setDate(start.getDate()+1);await mutate(`/api/v1/planner/allocations/${x.id}/copy?starts_at=${encodeURIComponent(start.toISOString())}`,'POST',null,true)}

View File

@ -0,0 +1,52 @@
/* Flatpickr is already used by time tracking. Keep API values in their original ISO format. */
(() => {
'use strict';
function reset(scope) {
scope.querySelectorAll('input[data-planning-date-type]').forEach(input=>{
const type=input.dataset.planningDateType;
input._flatpickr?.destroy();input.type=type;delete input.dataset.planningDateType;
});
}
function mount(scope) {
if(typeof window.flatpickr!=='function')return;
scope.querySelectorAll('input[type="date"],input[type="datetime-local"],input[type="time"],input[data-planning-date-type]').forEach(input=>{
if(input._flatpickr){input._flatpickr.setDate(input.value,false);input._flatpickr.altInput.disabled=input.disabled;return;}
const type=input.type,withTime=type!=='date',timeOnly=type==='time';
const label=input.getAttribute('aria-label')||input.labels?.[0]?.textContent.trim()||input.closest('label')?.textContent.trim()||'Vælg dato';
input.dataset.planningDateType=type;
const instance=window.flatpickr(input,{
locale:{...window.flatpickr.l10ns.da,monthAriaLabel:'Måned',yearAriaLabel:'År',hourAriaLabel:'Time',minuteAriaLabel:'Minut'},disableMobile:true,enableTime:withTime,noCalendar:timeOnly,time_24hr:true,
minuteIncrement:5,dateFormat:timeOnly?'H:i':withTime?'Y-m-d\\TH:i':'Y-m-d',
altInput:true,altFormat:timeOnly?'H:i':withTime?'d.m.Y \\k\\l. H:i':'d.m.Y',
allowInput:false,static:!!input.closest('dialog'),monthSelectorType:'dropdown',
ariaDateFormat:'j. F Y',defaultHour:8,
onReady:(_,__,picker)=>{
picker.calendarContainer.classList.add('planning-picker');
picker.altInput.setAttribute('aria-label',label);
picker.altInput.classList.add('planning-date-input');
picker.altInput.required=input.required;picker.altInput.disabled=input.disabled;
picker.altInput.placeholder=timeOnly?'Vælg klokkeslæt':withTime?'Vælg dato og tid':'Vælg dato';
const footer=document.createElement('div');footer.className='planning-picker-footer';
const today=document.createElement('button');today.type='button';today.textContent='I dag';
today.onclick=()=>{picker.setDate(new Date(),true);if(!withTime)picker.close();};
const close=document.createElement('button');close.type='button';close.textContent='Færdig';close.onclick=()=>picker.close();
footer.append(today,close);picker.calendarContainer.append(footer);
},
});
const form=input.form;
if(form&&!form.dataset.planningDateValidation){
form.dataset.planningDateValidation='1';
form.addEventListener('submit',event=>{
const missing=[...form.querySelectorAll('input[data-planning-date-type]')].find(field=>field.required&&!field.disabled&&!field.value);
if(!missing)return;
event.preventDefault();event.stopImmediatePropagation();
let error=form.querySelector('.work-dialog-error,[role="alert"],.planning-date-error');
if(!error){error=document.createElement('p');error.className='planning-date-error text-danger';error.setAttribute('role','alert');form.append(error);}
error.textContent='Vælg en dato i de obligatoriske felter.';missing._flatpickr.open();
},true);
}
if(!input.required){const clear=document.createElement('button');clear.type='button';clear.textContent='Ryd';clear.onclick=()=>instance.clear();instance.calendarContainer.querySelector('.planning-picker-footer').prepend(clear);}
});
}
window.BMCPlanningDates={mount,reset};
})();

412
static/js/planning-work.js Normal file
View File

@ -0,0 +1,412 @@
/* Unified planning uses existing cases and time APIs. Opening work never starts time. */
(() => {
'use strict';
const root = document.querySelector('.work-page');
if (!root) return;
const uid = Number(root.dataset.userId);
const byId = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const local = date => new Date(date.getTime() - date.getTimezoneOffset()*60000).toISOString().slice(0,16);
const api = async (path, options={}) => {
const response = await fetch('/api/v1/planner'+path, {...options, headers: {'Content-Type':'application/json', ...(options.headers||{})}});
const text = await response.text();
let body;
try { body = JSON.parse(text); }
catch (_) { throw new Error(response.status >= 500 ? 'Planlægningen kunne ikke indlæses på grund af en serverfejl. Prøv igen efter databaseopdateringen.' : 'Serveren returnerede et ugyldigt svar. Genindlæs siden eller log ind igen.'); }
if (!response.ok) { const e = new Error(typeof body.detail === 'string' ? body.detail : body.detail?.message || 'Handlingen kunne ikke gennemføres'); e.detail=body.detail; throw e; }
return body;
};
let queue = {items:[],version:1}, metadata, calendar, dragged, target=uid, queueRequest=0, activityTypes=[];
const error = e => {byId('workError').textContent=e.message;byId('workError').hidden=false;};
const minutes = n => n == null ? 'Ukendt estimat' : `${Math.round(n)} min`;
function taskControls(row) {
const slots=row.planned_slots||[],id=row.sag_id||row.id;
const day=value=>value?String(value).split('-').reverse().join('.'):'Ikke sat';
const start=day(row.start_date)+(row.start_time?' kl. '+row.start_time.slice(0,5):'');
const deadline=row.deadline?new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(row.deadline)):'Ikke sat';
const buttons=[['start','Start','bi-calendar-plus',start,canEditDates()],['deadline','Deadline','bi-flag',deadline,canEditDates()],['slot','Allokeret','bi-calendar2-range',slots.length?`${slots.length} tidsrum`:'Ingen tider',metadata.editable_user_ids.includes(target)]];
return `<div class="work-task-values mt-2">${buttons.map(([kind,label,icon,value,allowed])=>`<button type="button" class="work-value-button" data-task-kind="${kind}" data-control-case="${id}" draggable="${allowed}" ${allowed?'':'disabled'} title="Træk ${label.toLowerCase()} til kalenderen, eller klik for at vælge"><span class="work-value-label"><span class="work-value-icon"><i class="bi ${icon}" aria-hidden="true"></i></span><span>${label}</span><i class="bi bi-grip-vertical work-value-grip" aria-hidden="true"></i></span><span class="work-value-content">${esc(value)}</span></button>`).join('')}</div>${slots.length?`<div class="work-slot-values mt-2"><span class="work-muted">Kommende tider · ${esc(metadata.users.find(u=>u.id===target)?.name||'')}</span><ul class="list-unstyled mb-0 mt-1">${slots.map(slot=>`<li><i class="bi bi-clock me-1" aria-hidden="true"></i>${esc(new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(slot.starts_at)))} – ${esc(new Intl.DateTimeFormat('da-DK',{dateStyle:caseDate(slot.starts_at)!==caseDate(slot.ends_at)?'short':undefined,timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(slot.ends_at)))}</li>`).join('')}</ul></div>`:''}`;
}
function reservationEvent(row,viewType) {
const reserved=row.kind==='work'||row.private;
const background=reserved&&viewType.startsWith('timeGrid');
return {id:'event-'+(row.event_id??row.id),title:(row.has_conflict||row.outside_work_hours?'⚠ ':'')+(reserved?'Allokeret: ':'')+row.title,start:row.starts_at,end:row.ends_at,
display:background?'background':'auto',color:row.has_conflict?'#b42318':row.outside_work_hours?'#b54708':undefined,
classNames:reserved?['work-reservation',...(row.has_conflict?['work-reservation-conflict']:[])]:[],
editable:!background&&!row.locked&&canModifyEvent(row),
extendedProps:{booking:row.private?null:row,reservation:reserved}};
}
function reservationMount(info) {
if(!info.event.extendedProps.reservation||info.event.display!=='background')return;
const booking=info.event.extendedProps.booking;
const label=document.createElement(booking?'button':'span');
label.className='work-reservation-label';label.textContent=info.event.title;
if(booking){label.type='button';label.title='Se eller ændr den reserverede tid';label.onclick=event=>{event.stopPropagation();event.preventDefault();eventDialog(booking);};}
info.el.append(label);
}
function renderQueue() {
const filter=byId('workFilter').value, search=byId('workSearch').value.toLocaleLowerCase();
const today=local(new Date()).slice(0,10);
const rows=queue.items.filter(r => {
if (search && ![r.titel,r.customer_name,r.type,r.sag_id,...(r.tags||[])].join(' ').toLocaleLowerCase().includes(search)) return false;
if (!filter) return true;
if (filter==='ready') return r.ready;
if (filter==='high') return ['urgent','high'].includes(r.priority);
if (filter==='today') return String(r.deadline||'').slice(0,10)===today;
if (filter==='overdue') return r.deadline && new Date(r.deadline)<new Date();
return r.work_status===filter||(filter==='working'&&r.active_timer);
});
byId('workQueueHeading').textContent=target===uid?'Min arbejdskø':'Kø · '+(metadata.users.find(u=>u.id===target)?.name||'Medarbejder');
byId('workQueue').innerHTML=rows.map(r=>`<article class="card work-card" data-task-id="${r.sag_id}" data-task-source="queue" data-sag="${r.sag_id}" draggable="${metadata.editable_user_ids.includes(target)}"><div class="work-row"><a href="/sag/${r.sag_id}"><i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>${r.pinned?'📌 ':''}#${r.sag_id} ${esc(r.titel)}</a></div><div class="work-muted mt-1">${esc(r.customer_name||'')} · ${esc(minutes(r.remaining_work_minutes))} · ${esc(({ready:'Klar',working:'I gang',paused:'På pause',waiting:'Afventer',done:'Færdigt'})[r.work_status]||r.work_status)}${r.active_timer?' · Timer aktiv':''}</div>${taskControls(r)}<div class="work-actions mt-2"><button class="btn btn-sm btn-outline-secondary" data-action="pause" data-id="${r.sag_id}">${r.work_status==='paused'?'Fortsæt':'Pause'}</button></div><details class="mt-2"><summary class="small text-secondary">Flere handlinger</summary><div class="work-actions mt-2"><button class="btn btn-sm btn-outline-secondary" data-action="up" data-id="${r.sag_id}" aria-label="Flyt op">↑</button><button class="btn btn-sm btn-outline-secondary" data-action="down" data-id="${r.sag_id}" aria-label="Flyt ned">↓</button><button class="btn btn-sm btn-outline-secondary" data-action="pin" data-id="${r.sag_id}">${r.pinned?'Frigør':'Fastlås'}</button><button class="btn btn-sm btn-outline-secondary" data-action="transfer" data-id="${r.sag_id}">Vælg anden medarbejder</button><button class="btn btn-sm btn-outline-secondary" data-action="details" data-id="${r.sag_id}">Estimat og sagsplan</button><button class="btn btn-sm btn-outline-secondary" data-action="done" data-id="${r.sag_id}">${r.work_status==='done'?'Genåbn arbejde':'Arbejdet færdigt'}</button><button class="btn btn-sm btn-outline-secondary" data-action="defer" data-id="${r.sag_id}">Udsæt</button><button class="btn btn-sm btn-outline-secondary" data-action="unassign" data-id="${r.sag_id}">Fjern fra køen</button></div></details></article>`).join('')||'<p class="work-muted">Ingen opgaver i denne visning.</p>';
}
async function loadQueue(more=false) {
const requestId=++queueRequest,limit=more?100:Math.min(500,Math.max(100,queue.items.length));const result=await api('/queue?user_id='+target+'&q='+encodeURIComponent(byId('workSearch').value)+'&limit='+limit+'&offset='+(more?queue.items.length:0));if(requestId!==queueRequest)return;
queue={...result,items:more?[...queue.items,...result.items]:result.items};
byId('workMore').hidden=result.items.length<limit;renderQueue();
}
async function change(id,values) {
try {await api(`/queue/${target}/${id}`,{method:'PATCH',body:JSON.stringify({version:queue.version,...values})}); await loadQueue();calendar.refetchEvents();}
catch(e){error(e);await loadQueue();}
}
byId('workQueue').addEventListener('click',async event => {
const button=event.target.closest('[data-action]');if(!button)return;
const id=Number(button.dataset.id), row=queue.items.find(r=>r.sag_id===id), action=button.dataset.action;
if(action==='pin')return change(id,{pinned:!row.pinned});
if(action==='pause'){if(target!==uid)return change(id,{work_status:row.work_status==='paused'?'ready':'paused'});const mode=row.work_status==='paused'?'resume':'pause';const timer_action=confirm(mode==='pause'?'Vil du også pause timeren på denne sag?':'Vil du også genoptage en pauset timer på denne sag?');try{await api(`/queue/${id}/${mode}`,{method:'POST',body:JSON.stringify({version:queue.version,timer_action})});await loadQueue();}catch(e){error(e);}return;}
if(action==='planning')return editTaskDates(id).catch(error);
if(action==='details')return editCasePlan(id).catch(error);
if(action==='unassign')return unassignTask({id,source:target,version:queue.version}).catch(error);
if(action==='transfer')return transferTask(row).catch(error);
if(action==='done')return change(id,{work_status:row.work_status==='done'?'ready':'done'});
if(action==='estimate') {const value=prompt('Individuelt estimat i minutter (tomt = ufordelt)',row.estimated_minutes??'');if(value===null)return;if(value!==''&&(!Number.isInteger(Number(value))||Number(value)<0))return error(new Error('Angiv et helt antal minutter'));return change(id,{estimated_minutes:value===''?null:Number(value)});}
if(action==='defer') {const value=prompt('Udsæt til dato (ÅÅÅÅ-MM-DD)');if(!value)return;const date=new Date(value+'T08:00:00');if(Number.isNaN(date.getTime()))return error(new Error('Ugyldig dato'));return change(id,{snoozed_until:date.toISOString()});}
const group=queue.items.filter(r=>r.pinned===row.pinned),index=group.indexOf(row);
if(action==='up' && index>0)return change(id,{before_sag_id:group[index-1].sag_id});
if(action==='down' && index<group.length-1)return change(id,{before_sag_id:group[index+2]?.sag_id??null});
});
byId('workQueue').addEventListener('dragover',e=>{if(taskDrag?.source===target&&taskDrag.kind==='reorder')e.preventDefault();});
byId('workQueue').addEventListener('drop',async e=>{if(!taskDrag||taskDrag.source!==target||taskDrag.kind!=='reorder')return;e.preventDefault();e.stopPropagation();const before=Number(e.target.closest('[data-task-id]')?.dataset.taskId);if(before===taskDrag.id)return;try{await api(`/queue/${target}/${taskDrag.id}`,{method:'PATCH',body:JSON.stringify({version:taskDrag.version,before_sag_id:before||null})});await loadQueue();taskMessage('Rækkefølgen er gemt.');}catch(err){error(err);await loadQueue();}});
byId('workFilter').addEventListener('change',renderQueue);let searchTimer;byId('workSearch').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{queue.items=[];loadQueue().catch(error);},200);});
byId('workRefresh').onclick=()=>loadQueue().catch(error);byId('workMore').onclick=()=>loadQueue(true).catch(error);
document.querySelectorAll('[data-work-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close());
const canModifyEvent=event=>(event.participants||[event.user_id]).every(id=>metadata.editable_user_ids.includes(id));
function eventDialog(event=null,start=new Date()) {
const form=byId('workEventForm');window.BMCPlanningDates?.reset(form);form.reset();form.querySelector('.work-dialog-error').textContent='';
form.dataset.originalStart=event?.starts_at||'';form.dataset.originalEnd=event?.ends_at||'';form.elements.event_id.value=event?.event_id||'';form.elements.version.value=event?.event_version||'';
for(const name of ['title','description','sag_id','kind']) if(event?.[name]!=null)form.elements[name].value=event[name];form.elements.case_search.value=event?.sag_id||'';
for(const option of form.elements.user_ids.options)option.selected=event?.participants?.includes(Number(option.value))??Number(option.value)===uid;
form.elements.starts_at.value=local(event?new Date(event.starts_at):start);
form.elements.ends_at.value=local(event?new Date(event.ends_at):new Date(start.getTime()+3600000));
const editable=event?canModifyEvent(event)&&!event.locked:metadata.editable_user_ids.length>0;for(const field of form.querySelectorAll('input,select,textarea'))field.disabled=!editable;form.querySelector('.btn-primary').hidden=!editable;byId('workDeleteEvent').hidden=!event||!editable;window.BMCPlanningDates?.mount(form);byId('workEventDialog').showModal();
}
let caseSearchTimer,lastAutoTitle='';
byId('workEventForm').elements.case_search.oninput=e=>{
clearTimeout(caseSearchTimer);const value=e.target.value.trim(),form=byId('workEventForm');form.elements.sag_id.value=/^\d+$/.test(value)?value:'';
if(!value){byId('workCaseSuggestions').innerHTML='';return;}
caseSearchTimer=setTimeout(async()=>{try{const result=await api('/cases?q='+encodeURIComponent(value)+'&limit=20');byId('workCaseSuggestions').innerHTML=result.items.map(r=>`<option value="${r.id}">${esc(r.title)} · ${esc(r.customer_name)}</option>`).join('');const row=result.items.find(r=>String(r.id)===value);if(row&&(!form.elements.title.value||form.elements.title.value===lastAutoTitle)){form.elements.title.value=row.title;lastAutoTitle=row.title;}}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}},200);
};
byId('workCreate').onclick=()=>eventDialog();
byId('workAbsence').onclick=()=>absenceDialog();
async function transferTask(row) {
const dialog=document.createElement('dialog');root.append(dialog);
dialog.innerHTML=`<form><h2 class="h5">Overdrag arbejde · #${row.sag_id}</h2><p>Den ansvarlige på sagen bevares.</p><label>Medarbejder<select name="employee" class="form-select" required>${metadata.users.filter(u=>u.id!==target&&metadata.editable_user_ids.includes(u.id)).map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join('')}</select></label><p class="text-danger" role="alert"></p><button class="btn btn-primary">Overdrag</button> <button type="button" class="btn btn-light" data-close>Luk</button></form>`;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
dialog.querySelector('form').onsubmit=async e=>{e.preventDefault();const to=Number(e.target.elements.employee.value);try{const destination=await api('/queue?user_id='+to+'&limit=1');if(!await transferWithBookings(row.sag_id,{source_user_id:target,target_user_id:to,source_version:queue.version,target_version:destination.version}))return;dialog.close();dialog.remove();await loadQueue();calendar.refetchEvents();}catch(err){dialog.querySelector('[role=alert]').textContent=err.message;}};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
async function editSettings(config) {
const schedule=await api('/work-schedules/'+target),dialog=document.createElement('dialog');root.append(dialog);
const labels=['Mandag','Tirsdag','Onsdag','Torsdag','Fredag','Lørdag','Søndag'];
dialog.innerHTML=`<h2 class="h5">Planlægningsindstillinger</h2><form data-general><label>Advarsel før aftale (minutter)<input class="form-control" name="warning" type="number" min="0" max="1440" value="${config.appointment_warning_minutes}"></label><label>Deadlinepåmindelse (dage før, 0 = slået fra)<input class="form-control" name="deadline_days" type="number" min="0" max="30" value="${config.deadline_warning_days??1}"></label><button class="btn btn-primary">Gem generelt</button><p role="status"></p></form><hr><h3 class="h6">Arbejdstid · ${esc(metadata.users.find(u=>u.id===target)?.name)}</h3><form data-schedule>${labels.map((label,d)=>{const row=schedule.find(r=>r.weekday===d);return `<div class="row g-1 align-items-center mt-1"><label class="col-4"><input name="active_${d}" type="checkbox" ${row?'checked':''}> ${label}</label><div class="col-3"><input class="form-control form-control-sm" type="time" name="start_${d}" value="${row?.start_time||'08:00'}" aria-label="${label} start"></div><div class="col-3"><input class="form-control form-control-sm" type="time" name="end_${d}" value="${row?.end_time||'16:00'}" aria-label="${label} slut"></div><div class="col-2"><input class="form-control form-control-sm" type="number" min="0" name="break_${d}" value="${row?.break_minutes??30}" aria-label="${label} pause i minutter"></div></div>`;}).join('')}<p class="work-muted mt-2">Sidste kolonne er pause i minutter.</p><button class="btn btn-primary">Gem arbejdstid</button><p role="status"></p></form><hr><h3 class="h6">Aktivitets- og fraværstyper</h3><form data-type><label>Type<select name="code" class="form-select"><option value="">Ny type</option></select></label><label>Navn<input name="label" class="form-control" required maxlength="80"></label><label><input name="active" type="checkbox" checked> Aktiv</label><label><input name="absence" type="checkbox" checked> Fravær</label><button class="btn btn-primary">Gem type</button><p role="status"></p></form><button class="btn btn-light mt-3" data-close>Luk</button>`;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
dialog.querySelector('[data-general]').onsubmit=async e=>{e.preventDefault();try{Object.assign(config,await api('/configuration',{method:'PUT',body:JSON.stringify({appointment_warning_minutes:Number(e.target.elements.warning.value),deadline_warning_days:Number(e.target.elements.deadline_days.value)})}));e.target.querySelector('[role=status]').textContent='Gemt';}catch(err){e.target.querySelector('[role=status]').textContent=err.message;}};
dialog.querySelector('[data-schedule]').onsubmit=async e=>{e.preventDefault();const f=e.target;const days=labels.map((_,d)=>d).filter(d=>f.elements['active_'+d].checked).map(d=>({weekday:d,start_time:f.elements['start_'+d].value,end_time:f.elements['end_'+d].value,break_minutes:Number(f.elements['break_'+d].value)}));try{await api('/work-schedules/'+target,{method:'PUT',body:JSON.stringify({days})});f.querySelector('[role=status]').textContent='Gemt';calendar.refetchEvents();}catch(err){f.querySelector('[role=status]').textContent=err.message;}};
const types=await api('/activity-types'),form=dialog.querySelector('[data-type]');form.elements.code.insertAdjacentHTML('beforeend',types.map(t=>`<option value="${esc(t.code)}">${esc(t.label)}</option>`).join(''));
form.elements.code.onchange=()=>{const value=types.find(t=>t.code===form.elements.code.value);form.elements.label.value=value?.label||'';form.elements.active.checked=value?.active??true;form.elements.absence.checked=value?.is_absence??true;};
form.onsubmit=async e=>{e.preventDefault();const code=form.elements.code.value||form.elements.label.value.toLowerCase().replaceAll('æ','ae').replaceAll('ø','oe').replaceAll('å','aa').replace(/[^a-z]+/g,'_').replace(/^_|_$/g,'').slice(0,40);try{await api('/activity-types/'+code,{method:'PUT',body:JSON.stringify({label:form.elements.label.value,active:form.elements.active.checked,is_absence:form.elements.absence.checked})});form.querySelector('[role=status]').textContent='Gemt';const all=await api('/activity-types');activityTypes=all;byId('workAbsenceForm').elements.kind.innerHTML=all.filter(t=>t.active&&t.is_absence).map(t=>`<option value="${esc(t.code)}">${esc(t.label)}</option>`).join('');calendar.refetchEvents();}catch(err){form.querySelector('[role=status]').textContent=err.message;}};
const scope=await api('/team-scope/'+target),scopeForm=document.createElement('form');
scopeForm.innerHTML=`<hr><h3 class="h6">Adgang til kollegers planlægning</h3><p class="work-muted">Gælder ${esc(metadata.users.find(u=>u.id===target)?.name)}. Medarbejderens rolle afgør, om der kan ses eller redigeres.</p><select name="employees" class="form-select" multiple size="6">${metadata.users.filter(u=>u.id!==target).map(u=>`<option value="${u.id}" ${scope.user_ids.includes(u.id)?'selected':''}>${esc(u.name)}</option>`).join('')}</select><button class="btn btn-primary mt-2">Gem adgang</button><p role="status"></p>`;
dialog.insertBefore(scopeForm,dialog.querySelector('[data-close]'));
scopeForm.onsubmit=async e=>{e.preventDefault();try{await api('/team-scope/'+target,{method:'PUT',body:JSON.stringify({user_ids:[...scopeForm.elements.employees.selectedOptions].map(o=>Number(o.value))})});scopeForm.querySelector('[role=status]').textContent='Gemt';}catch(err){scopeForm.querySelector('[role=status]').textContent=err.message;}};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
async function editCasePlan(id) {
const row=await api(`/cases/${id}/planning`), dialog=document.createElement('dialog'),canEditCase=metadata.permissions.some(p=>['cases.edit','tickets.edit'].includes(p));root.append(dialog);
const dateTime=v=>v?local(new Date(v)):'';
dialog.innerHTML=`<form><h2 class="h5">Sagsplan · #${id}</h2><p>${esc(row.titel)}</p><label>Sagsstart<input class="form-control" type="date" name="start_date" value="${esc(row.start_date||'')}"></label><label>Startklokkeslæt (valgfrit)<input class="form-control" type="time" name="start_time" value="${esc(row.start_time||'')}"></label><label>Intern deadline<input class="form-control" type="datetime-local" name="deadline" value="${dateTime(row.deadline)}"></label><label>Samlet estimat i minutter<input class="form-control" type="number" name="estimated_minutes" min="0" value="${row.estimated_minutes??''}"></label><h3 class="h6 mt-3">Fordeling mellem medarbejdere</h3>${row.assignees.map(a=>`<fieldset class="card work-card"><legend class="h6">${esc(a.name)}</legend><label>Estimat (minutter)<input type="number" min="0" name="estimate_${a.user_id}" class="form-control" value="${a.estimated_minutes??''}" ${metadata.editable_user_ids.includes(a.user_id)?'':'disabled'}></label><label>Manuelt resterende arbejde (valgfrit)<input type="number" min="0" name="remaining_${a.user_id}" class="form-control" value="${a.remaining_minutes??''}" ${metadata.editable_user_ids.includes(a.user_id)?'':'disabled'}></label></fieldset>`).join('')}<p class="work-muted">Tomt resterende arbejde beregnes fra individuelt estimat og registreret tid. Sagens samlede estimat fordeles ikke automatisk.</p>${metadata.permissions.some(p=>['planner.admin','planner.allocate'].includes(p))?`<label>Tilføj medarbejder<select name="new_user" class="form-select"><option value="">Ingen ny medarbejder</option>${metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id)&&!row.assignees.some(a=>a.user_id===u.id)).map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join('')}</select></label><label>Estimat til ny medarbejder (minutter)<input type="number" min="0" name="new_minutes" class="form-control" disabled></label>`:''}<p class="text-danger" role="alert"></p><button class="btn btn-primary">Gem sagsplan</button> <button type="button" class="btn btn-light" data-close>Luk</button></form>`;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
if(!canEditCase)for(const name of ['start_date','start_time','deadline','estimated_minutes'])dialog.querySelector('[name='+name+']').disabled=true;
if(dialog.querySelector('[name=new_user]'))dialog.querySelector('[name=new_user]').onchange=e=>{dialog.querySelector('[name=new_minutes]').disabled=!e.target.value;};
dialog.querySelector('form').onsubmit=async e=>{e.preventDefault();const data=new FormData(e.target);const body={updated_at:row.updated_at,start_date:data.get('start_date')||null,start_time:data.get('start_time')||null,deadline:data.get('deadline')===dateTime(row.deadline)?row.deadline:data.get('deadline')?new Date(data.get('deadline')).toISOString():null,estimated_minutes:data.get('estimated_minutes')===''?null:Number(data.get('estimated_minutes')),assignee_estimates:row.assignees.filter(a=>metadata.editable_user_ids.includes(a.user_id)).map(a=>({user_id:a.user_id,queue_version:a.queue_version,estimated_minutes:data.get('estimate_'+a.user_id)===''?null:Number(data.get('estimate_'+a.user_id)),remaining_minutes:data.get('remaining_'+a.user_id)===''?null:Number(data.get('remaining_'+a.user_id))})),add_assignee:data.get('new_user')?{user_id:Number(data.get('new_user')),estimated_minutes:data.get('new_minutes')===''?null:Number(data.get('new_minutes'))}:null};if(!canEditCase)for(const key of ['start_date','start_time','deadline','estimated_minutes'])delete body[key];try{await api(`/cases/${id}/planning`,{method:'PATCH',body:JSON.stringify(body)});dialog.close();dialog.remove();await loadQueue();calendar.refetchEvents();}catch(err){dialog.querySelector('[role=alert]').textContent=err.message;}};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
async function saveWithConflict(path,options) {
try{return await api(path,options);}catch(e){if(e.detail?.confirm_conflicts && confirm(e.message+'. Vil du gemme alligevel?'))return api(path,{...options,body:JSON.stringify({...JSON.parse(options.body),confirm_conflicts:true})});throw e;}
}
byId('workEventForm').onsubmit=async e=>{
e.preventDefault();const form=e.target,data=new FormData(form), id=data.get('event_id');
if(data.get('case_search')&&!data.get('sag_id')){form.querySelector('.work-dialog-error').textContent='Vælg en sag fra forslagene, eller ryd søgefeltet.';return;}const body={title:data.get('title'),description:data.get('description'),sag_id:Number(data.get('sag_id'))||null,kind:data.get('kind'),user_ids:data.getAll('user_ids').map(Number),starts_at:form.dataset.originalStart&&data.get('starts_at')===local(new Date(form.dataset.originalStart))?form.dataset.originalStart:new Date(data.get('starts_at')).toISOString(),ends_at:form.dataset.originalEnd&&data.get('ends_at')===local(new Date(form.dataset.originalEnd))?form.dataset.originalEnd:new Date(data.get('ends_at')).toISOString(),version:Number(data.get('version'))||null};
try{await saveWithConflict('/events'+(id?'/'+id:''),{method:id?'PATCH':'POST',body:JSON.stringify(body)});byId('workEventDialog').close();calendar.refetchEvents();await loadQueue();}catch(err){form.querySelector('.work-dialog-error').textContent=err.message;}
};
byId('workDeleteEvent').onclick=async()=>{const form=byId('workEventForm');if(!confirm('Annullér aftalen for alle deltagere?'))return;try{await api(`/events/${form.elements.event_id.value}?version=${form.elements.version.value}`,{method:'DELETE'});byId('workEventDialog').close();calendar.refetchEvents();}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}};
function absenceEvent(row) {
const label=row.kind==='unavailable'?'Fravær':activityTypes.find(t=>t.code===row.kind)?.label||'Fravær';
return {id:'absence-'+row.id,title:label,start:row.all_day?row.display_start:row.starts_at,
end:row.all_day?row.display_end:row.ends_at,allDay:!!row.all_day,color:'#64748b',
editable:false,extendedProps:{absence:row}};
}
let visibleAbsences=[];
function renderAbsences(rows) {
visibleAbsences=rows;
byId('workAbsenceSummary').innerHTML='<strong class="small">Ferie og fravær · vist kalenderperiode</strong>'+(rows.length?'<div class="d-flex flex-wrap gap-2 mt-2">'+rows.map(r=>`<button type="button" class="btn btn-sm btn-outline-secondary" data-absence-id="${r.id}"><i class="bi bi-calendar-minus me-1" aria-hidden="true"></i>${esc(absenceEvent(r).title)} · ${esc(caseDate(r.starts_at))} – ${esc(caseDate(new Date(new Date(r.ends_at).getTime()-(r.whole_days?1:0)).toISOString()))}</button>`).join('')+'</div>':'<span class="small text-secondary mt-1">Intet planlagt fravær i den viste periode. Vælg uge eller måned for et større overblik.</span>');
}
byId('workAbsenceSummary').onclick=e=>{const button=e.target.closest('[data-absence-id]');if(button)absenceDialog(visibleAbsences.find(r=>r.id===Number(button.dataset.absenceId)));};
function absenceDialog(row=null) {
const form=byId('workAbsenceForm');window.BMCPlanningDates?.reset(form);form.reset();form.querySelector('.work-dialog-error').textContent='';
const canRegister=id=>metadata.editable_user_ids.includes(id)&&metadata.permissions.some(p=>p==='planner.admin'||p===(id===uid?'planner.absence_own':'planner.absence_team'));
const allowed=metadata.users.filter(u=>canRegister(u.id)),selected=row?.user_id||(allowed.some(u=>u.id===target)?target:allowed[0]?.id);
const editable=!!selected&&canRegister(selected)&&!row?.registered,reasonVisible=!row||row.kind!=='unavailable';
form.elements.user_id.innerHTML=metadata.users.map(u=>`<option value="${u.id}" ${u.id===selected?'selected':''} ${row||canRegister(u.id)?'':'disabled'}>${esc(u.name)}</option>`).join('');
form.elements.kind.innerHTML=reasonVisible?activityTypes.filter(t=>t.is_absence&&(t.active||t.code===row?.kind)).map(t=>`<option value="${esc(t.code)}" ${t.code===row?.kind?'selected':''}>${esc(t.label)}</option>`).join(''):'<option>Ikke tilgængelig</option>';
form.dataset.originalStart=row?.starts_at||'';form.dataset.originalEnd=row?.ends_at||'';form.elements.absence_id.value=row?.id||'';form.elements.version.value=row?.version||'';
if(row){form.elements.starts_at.value=local(new Date(row.starts_at));form.elements.ends_at.value=local(new Date(row.ends_at));form.elements.note.value=row.note||'';}
form.elements.user_id.disabled=!!row||!editable;form.elements.kind.disabled=!editable||!reasonVisible;form.elements.note.disabled=!editable||!reasonVisible;
form.elements.whole_days.checked=!row||!!row.whole_days;
form.elements.whole_days.disabled=!editable;
const changePeriodMode=()=>{
window.BMCPlanningDates?.reset(form);
const whole=form.elements.whole_days.checked;
for(const name of ['starts_at','ends_at']) {
const input=form.elements[name],value=input.value;
input.type=whole?'date':'datetime-local';input.value=whole?value.slice(0,10):value.length===10?value+'T08:00':value;
}
};
changePeriodMode();
if(row&&form.elements.whole_days.checked){const last=new Date(row.display_end+'T12:00:00Z');last.setUTCDate(last.getUTCDate()-1);form.elements.starts_at.value=row.display_start;form.elements.ends_at.value=last.toISOString().slice(0,10);}
form.elements.whole_days.onchange=()=>{changePeriodMode();window.BMCPlanningDates?.mount(form);};
form.elements.starts_at.disabled=!editable;form.elements.ends_at.disabled=!editable;
form.querySelector('.btn-primary').hidden=!editable;byId('workCancelAbsence').hidden=!row||!editable;
byId('workRegisterAbsence').hidden=!row||!editable||new Date(row.ends_at)>new Date();
window.BMCPlanningDates?.mount(form);byId('workAbsenceDialog').showModal();
}
byId('workAbsenceForm').onsubmit=async e=>{e.preventDefault();const form=e.target,data=new FormData(form),id=data.get('absence_id');const body={user_id:Number(form.elements.user_id.value),kind:form.elements.kind.disabled?null:data.get('kind'),starts_at:form.dataset.originalStart&&data.get('starts_at')===local(new Date(form.dataset.originalStart))?form.dataset.originalStart:new Date(data.get('starts_at')).toISOString(),ends_at:form.dataset.originalEnd&&data.get('ends_at')===local(new Date(form.dataset.originalEnd))?form.dataset.originalEnd:new Date(data.get('ends_at')).toISOString(),version:Number(data.get('version'))||null};if(form.elements.whole_days.checked){body.whole_day_start=data.get('starts_at');body.whole_day_end=data.get('ends_at');}if(!form.elements.note.disabled)body.note=data.get('note');try{const r=await api('/absences'+(id?'/'+id:''),{method:id?'PATCH':'POST',body:JSON.stringify(body)});byId('workAbsenceDialog').close();calendar.refetchEvents();if(r.affected_allocations.length)alert(`${r.affected_allocations.length} bookinger påvirkes af fraværet.`);}catch(err){form.querySelector('.work-dialog-error').textContent=err.message;}};
byId('workRegisterAbsence').onclick=async()=>{const form=byId('workAbsenceForm');if(!confirm('Registrer fraværet som faktisk afholdt, ikke-fakturerbar tid?'))return;try{await api(`/absences/${form.elements.absence_id.value}/register?version=${form.elements.version.value}`,{method:'POST'});byId('workAbsenceDialog').close();calendar.refetchEvents();}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}};
byId('workCancelAbsence').onclick=async()=>{const form=byId('workAbsenceForm');if(!confirm('Annullér det planlagte fravær?'))return;try{await api(`/absences/${form.elements.absence_id.value}?version=${form.elements.version.value}`,{method:'DELETE'});byId('workAbsenceDialog').close();calendar.refetchEvents();}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}};
async function next() {
const result=await api('/next-task');
if(result.task&&!result.warning){location.assign(result.task.url);return;}
byId('workNextMessage').textContent=result.warning?`Kommende aftale ${new Date(result.appointment.starts_at).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'})}. Næste opgave: ${result.task.title} (${minutes(result.task.remaining_minutes)}).`:result.message;
const actions=[];if(result.task)actions.push([result.task.url,'Åbn opgaven alligevel']);if(result.appointment?.sag_id)actions.push(['/sag/'+result.appointment.sag_id,'Åbn fast aftale']);if(result.shorter_task)actions.push([result.shorter_task.url,'Vælg kortere opgave']);actions.push(['/planning?view=queue','Vis arbejdskø'],['/planning?view=calendar','Vis kalender']);
byId('workNextActions').innerHTML=actions.map(([url,label])=>`<a class="btn btn-outline-primary" href="${esc(url)}">${esc(label)}</a>`).join('');byId('workNextDialog').showModal();
}
document.querySelectorAll('[data-work-next]').forEach(b=>b.onclick=()=>next().catch(error));
let unassignedRows=[],unassignedOffset=0,unassignedRequest=0,unassignedSearchTimer;
const helpBubble=(text,label)=>`<details class="work-help"><summary aria-label="${esc(label)}"><i class="bi bi-info-circle" aria-hidden="true"></i></summary><div>${esc(text)}</div></details>`;
async function loadUnassigned(offset=0) {
const request=++unassignedRequest,limit=25;
byId('unassignedList').setAttribute('aria-busy','true');byId('unassignedError').hidden=true;
byId('unassignedStatus').textContent='Henter sager…';
byId('unassignedPrev').disabled=true;byId('unassignedNext').disabled=true;
try {
const query=byId('unassignedSearch').value.trim();
const data=await api(`/unassigned?limit=${limit}&offset=${offset}&q=${encodeURIComponent(query)}`);
if(request!==unassignedRequest)return;
if(!data.items.length&&offset>0)return loadUnassigned(Math.max(0,offset-limit));
unassignedRows=data.items;unassignedOffset=offset;
byId('unassignedList').innerHTML=data.items.length?`<div class="table-responsive"><table class="table table-hover unassigned-table mb-0"><thead><tr><th scope="col">Sag / kunde og planlægning</th><th scope="col">Sagens estimat</th><th scope="col"><span class="visually-hidden">Handling</span></th></tr></thead><tbody>${data.items.map(row=>{
const deadline=row.deadline?new Date(row.deadline):null;
const overdue=deadline&&deadline<new Date();
const priority=({urgent:'Haster',high:'Høj prioritet'})[row.priority];
return `<tr data-task-id="${row.id}" data-task-source="inbox" draggable="true"><td><span class="work-drag-hint me-1" aria-hidden="true"><i class="bi bi-grip-vertical"></i></span><a class="case-name" href="/sag/${row.id}"><span class="case-number">#${row.id}</span>${esc(row.titel)}</a>${priority?` <span class="badge ${row.priority==='urgent'?'text-bg-danger':'text-bg-warning'}">${priority}</span>`:''}<div class="work-muted mt-1">${esc(row.customer_name||'Ingen kunde')} · Ansvarlig: ${esc(row.responsible_name||'Ikke valgt')}</div>${taskControls(row)}</td><td class="work-legacy-deadline" data-label="Deadline"><span class="${overdue?'text-danger fw-semibold':''}">${deadline?esc(deadline.toLocaleDateString('da-DK',{timeZone:'Europe/Copenhagen'})):'Ingen deadline'}</span>${overdue?'<div class="small text-danger">Overskredet</div>':''}</td><td data-label="Sagens estimat">${row.estimated_minutes==null?'<span class="text-secondary">Ikke angivet</span>':esc(row.estimated_minutes)+' min'}</td><td class="text-md-end"><button type="button" class="btn btn-sm btn-primary" data-assign-case="${row.id}" aria-label="Vælg medarbejder til sag ${row.id}"><i class="bi bi-person-plus me-1" aria-hidden="true"></i>Vælg medarbejder</button></td></tr>`;
}).join('')}</tbody></table></div>`:`<div class="unassigned-empty"><i class="bi ${query?'bi-search':'bi-check-circle'} d-block mb-2" aria-hidden="true"></i><h3 class="h6">${query?'Ingen sager matcher din søgning':'Alle åbne sager er fordelt'}</h3><p class="mb-0">${query?'Prøv et andet sagsnummer, navn eller kundenavn.':'Sager uden en medarbejder vises her, når de bliver oprettet.'}</p></div>`;
byId('unassignedPage').textContent=data.items.length?`Viser ${offset+1}–${offset+data.items.length}${data.has_more?' · flere sager på næste side':''}`:'';
byId('unassignedPrev').disabled=offset===0;byId('unassignedNext').disabled=!data.has_more;
byId('unassignedStatus').textContent='';
} catch(err) {
if(request!==unassignedRequest)return;
byId('unassignedError').textContent=err.message;byId('unassignedError').hidden=false;
byId('unassignedStatus').textContent='Listen kunne ikke opdateres. Prøv igen.';
} finally {if(request===unassignedRequest)byId('unassignedList').setAttribute('aria-busy','false');}
}
function assignUnassigned(row) {
const employees=metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id));
const dialog=document.createElement('dialog');dialog.setAttribute('aria-labelledby','assignCaseHeading');root.append(dialog);
dialog.innerHTML=`<form><h2 id="assignCaseHeading" class="h5">Tilføj sag til en medarbejders kø</h2><p class="fw-semibold mb-1">#${row.id} ${esc(row.titel)}</p><p class="work-muted">${esc(row.customer_name||'Ingen kunde')}</p><label for="assignEmployee">Hvem skal udføre arbejdet? <span class="text-danger" aria-hidden="true">*</span></label><select id="assignEmployee" name="employee" class="form-select" required><option value="">Vælg medarbejder…</option>${employees.map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join('')}</select><div class="d-flex align-items-center gap-1 mt-3"><label for="assignMinutes" class="mb-0">Forventet arbejdstid <span class="work-muted">(valgfrit)</span></label>${helpBubble('Angiv kun den tid, denne medarbejder forventes at bruge på sagen. 60 minutter er 1 time. Lad feltet stå tomt, hvis du ikke ved det endnu. Sagens samlede estimat ændres ikke.','Hjælp til forventet arbejdstid')}</div><div class="input-group mt-1"><input id="assignMinutes" name="minutes" type="number" min="0" max="525600" step="1" class="form-control" placeholder="Fx 60"><span class="input-group-text">minutter</span></div><div class="small text-secondary mt-3"><i class="bi bi-info-circle me-1" aria-hidden="true"></i>Sagen tilføjes nederst i medarbejderens kø. Den ansvarlige på sagen ændres ikke.</div><details class="mt-2 small"><summary>Skal arbejdet også lægges i kalenderen?</summary><p class="text-secondary mt-2 mb-0">Kun hvis der er aftalt et bestemt tidspunkt. Tilføj først sagen til køen. Brug derefter “Ny aftale” til at reservere tid i kalenderen.</p></details><p class="text-danger mt-3" role="alert"></p><div class="work-actions justify-content-end mt-3"><button type="button" class="btn btn-outline-secondary" data-close>Annullér</button><button type="submit" class="btn btn-primary" ${employees.length?'':'disabled'}>Tilføj til kø</button></div>${employees.length?'':'<p class="text-secondary mt-2">Du har ikke adgang til at tildele arbejde til en medarbejder.</p>'}</form>`;
const form=dialog.querySelector('form'),save=form.querySelector('[type=submit]'),cancel=form.querySelector('[data-close]');
const close=()=>{dialog.close();dialog.remove();byId('unassignedSearch').focus();};cancel.onclick=close;
dialog.addEventListener('close',()=>dialog.remove());
form.onsubmit=async event=>{
event.preventDefault();if(save.disabled)return;
const employee=Number(form.elements.employee.value),value=form.elements.minutes.value;
if(!employee)return;
save.disabled=true;cancel.disabled=true;save.textContent='Tilføjer…';form.querySelector('[role=alert]').textContent='';
try {
const allocation=await api(`/cases/${row.id}/assignees`,{method:'POST',body:JSON.stringify({user_id:employee,estimated_minutes:value===''?null:Number(value)})});
const name=employees.find(u=>u.id===employee)?.name||'medarbejderen';close();await loadUnassigned(unassignedOffset);
byId('unassignedStatus').textContent=allocation.created?`Sag #${row.id} er nu i køen hos ${name}.`:`Sag #${row.id} er allerede i køen hos ${name}. Den forventede arbejdstid er uændret.`;
const link=document.createElement('a');link.className='ms-2';link.textContent='Åbn medarbejderens kø';
link.href='/planning?view=queue&user_id='+employee;byId('unassignedStatus').append(link);
await loadQueue();calendar.refetchEvents();
} catch(err) {form.querySelector('[role=alert]').textContent=err.message;save.disabled=false;cancel.disabled=false;save.textContent='Tilføj til kø';}
};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();form.elements.employee.focus();
}
byId('unassignedList').onclick=event=>{const dates=event.target.closest('[data-task-dates]');if(dates)return editTaskDates(Number(dates.dataset.taskDates)).catch(error);const button=event.target.closest('[data-assign-case]');const row=unassignedRows.find(r=>r.id===Number(button?.dataset.assignCase));if(row)assignUnassigned(row);};
byId('unassignedSearch').oninput=()=>{clearTimeout(unassignedSearchTimer);unassignedSearchTimer=setTimeout(()=>loadUnassigned(),250);};
byId('unassignedRefresh').onclick=()=>loadUnassigned(unassignedOffset);
byId('unassignedPrev').onclick=()=>loadUnassigned(Math.max(0,unassignedOffset-25));
byId('unassignedNext').onclick=()=>loadUnassigned(unassignedOffset+25);
root.addEventListener('click',event=>root.querySelectorAll('.work-help[open]').forEach(details=>{if(!details.contains(event.target))details.open=false;}));
let taskDrag=null,taskSource='inbox',currentView='work',taskBusy=false;
byId('workSourcePane').append(byId('workUnassigned'));
const caseDate=value=>value?new Intl.DateTimeFormat('en-CA',{timeZone:'Europe/Copenhagen',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date(value)):'';
const canEditDates=()=>metadata.permissions.some(p=>['cases.edit','tickets.edit'].includes(p));
function taskFromElement(element) {
const id=Number(element?.dataset.taskId);if(!id)return null;
const source=element.dataset.taskSource;
const row=(source==='inbox'?unassignedRows:queue.items).find(r=>(r.sag_id||r.id)===id);
return row?{id,title:row.titel,updated_at:row.case_updated_at,source:source==='inbox'?null:target,version:queue.version}:null;
}
function taskMessage(message) {byId('workTaskStatus').textContent=message;byId('workError').hidden=true;}
async function refreshTasks() {await Promise.all([loadQueue(),currentView==='tasks'?loadUnassigned(unassignedOffset):Promise.resolve()]);calendar.refetchEvents();}
async function transferWithBookings(id,body) {
const path=`/cases/${id}/transfer`;
try {await api(path,{method:'POST',body:JSON.stringify(body)});return true;}
catch(err) {
if(err.detail?.code!=='future_bookings_choice')throw err;
const detail=err.detail,employee=metadata.users.find(u=>u.id===body.target_user_id)?.name||'den nye medarbejder';
const action=await new Promise(resolve=>{
const dialog=document.createElement('dialog');root.append(dialog);
dialog.innerHTML=`<h2 class="h5">Overdrag sag #${id}</h2><p>Sagen har <strong>${detail.booking_count} kommende reservationer</strong>. Hvad skal der ske med dem?</p><div class="d-grid gap-2"><button type="button" class="btn btn-primary" data-action="transfer">Overdrag tiderne til ${esc(employee)}</button><p class="small text-muted">Tidspunkterne bevares i den nye medarbejders kalender.</p><button type="button" class="btn btn-outline-danger" data-action="remove">Fjern de reserverede tider</button><p class="small text-muted">Den tidligere medarbejders tider frigives. Andre deltagere og registreret tid bevares.</p><button type="button" class="btn btn-light" data-action="cancel">Annullér overdragelse</button></div>`;
const finish=action=>{dialog.close();dialog.remove();resolve(action);};
dialog.querySelectorAll('[data-action]').forEach(button=>button.onclick=()=>finish(button.dataset.action));
dialog.oncancel=e=>{e.preventDefault();finish(null);};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
});
if(!action||action==='cancel')return false;
await saveWithConflict(path,{method:'POST',body:JSON.stringify({...body,future_bookings_action:action,future_bookings:detail.bookings})});return true;
}
}
async function assignTask(task,employee) {
if(taskBusy)return;taskBusy=true;
try {
if(task.source===employee){await showTaskSource('queue');return;}
if(task.source){
const destination=await api('/queue?user_id='+employee+'&limit=1');
if(!await transferWithBookings(task.id,{source_user_id:task.source,target_user_id:employee,source_version:task.version,target_version:destination.version}))return;
} else await api(`/cases/${task.id}/assignees`,{method:'POST',body:JSON.stringify({user_id:employee})});
await refreshTasks();taskMessage(`Sag #${task.id} er nu hos ${metadata.users.find(u=>u.id===employee)?.name||'medarbejderen'}. Den ansvarlige er uændret.`);
} catch(err){error(err);await refreshTasks();}finally{taskBusy=false;}
}
async function unassignTask(task) {
if(!task.source||taskBusy)return;taskBusy=true;
try {await api(`/cases/${task.id}/assignees/${task.source}?version=${task.version}`,{method:'DELETE'});await refreshTasks();taskMessage(`Sag #${task.id} er fjernet fra medarbejderens kø. Andre tildelinger og ansvarlig er bevaret.`);}
catch(err){error(err);}finally{taskBusy=false;}
}
async function setTaskDate(task,day,kind,instant=null) {
if(!canEditDates())throw new Error('Du har ikke rettighed til at ændre sagens datoer.');
if(!task.updated_at)throw new Error('Opgavelisten skal genindlæses. Genindlæs siden, og træk opgaven igen.');
if(!/^\d{4}-\d{2}-\d{2}$/.test(day))throw new Error('Vælg en dato i kalenderen.');
const body={updated_at:task.updated_at};body[kind==='deadline'?(instant?'deadline':'deadline_date'):'start_date']=kind==='deadline'&&instant?instant:day;if(kind==='start'&&instant)body.start_time=instant.slice(11,19);
await api(`/cases/${task.id}/planning`,{method:'PATCH',body:JSON.stringify(body)});
await refreshTasks();taskMessage(`${kind==='deadline'?'Deadline':'Startdato'} for sag #${task.id} er sat til ${day.split('-').reverse().join('.')}.`);
}
async function editTaskDates(id,kind=null) {
const plan=await api(`/cases/${id}/planning`),dialog=document.createElement('dialog');root.append(dialog);
dialog.innerHTML=`<form><h2 class="h5">${kind==='start'?'Sæt start':kind==='deadline'?'Sæt deadline':'Start og deadline'}</h2><p class="fw-semibold">#${id} ${esc(plan.titel)}</p><label ${kind==='deadline'?'hidden':''}>Startdato<input class="form-control" name="start" type="date" value="${esc(plan.start_date||'')}"></label><label ${kind==='start'?'hidden':''}>Deadline<input class="form-control" name="deadline" type="date" value="${caseDate(plan.deadline)}"></label><p class="work-muted">Startdato er den tidligste dag, arbejdet kan begynde. Deadline er dagen, det skal være færdigt. En ny deadline sættes kl. 17; et eksisterende klokkeslæt bevares.</p><p role="alert" class="text-danger"></p><div class="work-actions justify-content-end"><button type="button" class="btn btn-outline-secondary" data-close>Luk</button><button class="btn btn-primary" ${canEditDates()?'':'disabled'}>Gem datoer</button></div></form>`;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
dialog.querySelector('form').onsubmit=async event=>{event.preventDefault();const form=event.target,button=form.querySelector('.btn-primary');button.disabled=true;
const body={updated_at:plan.updated_at};if(kind!=='deadline'){body.start_date=form.elements.start.value||null;if(!body.start_date)body.start_time=null;}if(kind!=='start')body.deadline_date=form.elements.deadline.value||null;
try{await api(`/cases/${id}/planning`,{method:'PATCH',body:JSON.stringify(body)});dialog.close();dialog.remove();await refreshTasks();taskMessage(`Datoerne for sag #${id} er gemt.`);}catch(err){form.querySelector('[role=alert]').textContent=err.message;button.disabled=false;}};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
function planTaskSlot(task,day=caseDate(new Date()),clock='09:00') {
const employees=metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id));
const dialog=document.createElement('dialog');dialog.setAttribute('aria-labelledby','slotHeading');dialog.addEventListener('close',()=>dialog.remove(),{once:true});root.append(dialog);
const start=new Date(day+'T'+(clock||'09:00')),end=new Date(start.getTime()+(metadata.settings?.default_allocation_minutes||60)*60000);
dialog.innerHTML=`<form><h2 class="h5" id="slotHeading">Allokér et nyt tidsrum</h2><p class="fw-semibold">#${task.id} ${esc(task.title)}</p><label>Medarbejder<select name="employee" class="form-select" required>${employees.map(u=>`<option value="${u.id}" ${u.id===target?'selected':''}>${esc(u.name)}</option>`).join('')}</select></label><label>Fra<input type="datetime-local" name="start" class="form-control" value="${local(start)}" required></label><label>Til<input type="datetime-local" name="end" class="form-control" value="${local(end)}" required></label><p class="work-muted">Tiden reserveres til opgaven. Den faktiske arbejdstid registrerer du bagefter med timeren eller manuelt. Eksisterende reservationer bevares, og du kan tilføje flere tidsrum.</p><p role="alert" class="text-danger"></p><div class="work-actions justify-content-end"><button type="button" class="btn btn-outline-secondary" data-close>Annullér</button><button class="btn btn-primary" ${employees.length?'':'disabled'}>Tilføj tidsrum</button></div></form>`;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
dialog.querySelector('form').onsubmit=async event=>{event.preventDefault();const form=event.target,button=form.querySelector('.btn-primary');button.disabled=true;
const employee=Number(form.elements.employee.value);
try {await saveWithConflict('/events',{method:'POST',body:JSON.stringify({sag_id:task.id,title:task.title,user_ids:[employee],kind:'work',starts_at:new Date(form.elements.start.value).toISOString(),ends_at:new Date(form.elements.end.value).toISOString()})});dialog.close();dialog.remove();target=employee;byId('workEmployee').value=employee;await loadQueue();await showTaskSource('queue');calendar.refetchEvents();taskMessage(`Et nyt tidsrum er tilføjet på sag #${task.id}. Eksisterende tider er bevaret.`);}
catch(err){form.querySelector('[role=alert]').textContent=err.message;button.disabled=false;}
};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
function calendarTaskTarget(event) {
const host=byId('workCalendar'),x=event.clientX,y=event.clientY;
let cell=event.target.closest('[data-date]');
// Time-grid lanes span every day. Their DOM ancestors have no date;
// resolve the overlaid day column from its rendered screen bounds.
if(!cell&&Number.isFinite(x)&&Number.isFinite(y)){
cell=[...host.querySelectorAll('.fc-timegrid-col[data-date],.fc-daygrid-day[data-date],.fc-col-header-cell[data-date]')].find(el=>{
const rect=el.getBoundingClientRect();
return rect.width>0&&rect.height>0&&x>=rect.left&&x<rect.right&&y>=rect.top&&y<rect.bottom;
});
}
const day=cell?.dataset.date||(['timeGridDay','listDay'].includes(calendar.view.type)?caseDate(calendar.getDate()):null);
let clock=null;
if(calendar.view.type.startsWith('timeGrid')&&Number.isFinite(event.clientY)){
const lane=[...byId('workCalendar').querySelectorAll('.fc-timegrid-slot-lane[data-time]')].find(el=>{const rect=el.getBoundingClientRect();return event.clientY>=rect.top&&event.clientY<rect.bottom;});
clock=lane?.dataset.time?.slice(0,5)||null;
}
return {day,clock,cell};
}
root.addEventListener('click',event=>{
const button=event.target.closest('[data-control-case]');if(!button||button.disabled)return;
const row=button.closest('[data-task-id]'),task=taskFromElement(row);if(!task)return;
if(button.dataset.taskKind==='slot')planTaskSlot(task);
else editTaskDates(task.id,button.dataset.taskKind).catch(error);
});
async function showTaskSource(source) {
if(currentView==='tasks'){taskSource=source;const url=new URL(location.href);url.searchParams.set('view','tasks');url.searchParams.set('source',source);url.searchParams.set('user_id',String(target));history.replaceState(null,'',url.pathname+url.search);}byId('workQueuePanel').hidden=currentView==='tasks'&&source!=='queue';byId('workUnassigned').hidden=currentView!=='tasks'||source!=='inbox';
root.querySelectorAll('[data-task-source].btn').forEach(button=>{button.classList.toggle('active',button.dataset.taskSource===source);button.setAttribute('aria-pressed',String(button.dataset.taskSource===source));});
if(currentView==='tasks'&&source==='inbox')await loadUnassigned();
}
function renderEmployeeDrops() {
byId('workEmployeeDrops').innerHTML=metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id)).map(u=>`<button type="button" class="work-employee-drop" data-drop-user="${u.id}" title="Træk en opgave hertil, eller klik for at se køen"><i class="bi bi-person me-1" aria-hidden="true"></i>${esc(u.name)}</button>`).join('')+'<div class="work-employee-drop" data-drop-unassign title="Træk en opgave fra køen hertil for at fjerne medarbejderens tildeling"><i class="bi bi-inbox me-1" aria-hidden="true"></i>Træk hertil for at fjerne fra køen</div>';
}
byId('workSourceFilter').onclick=event=>{const button=event.target.closest('[data-task-source]');if(button)showTaskSource(button.dataset.taskSource).catch(error);};
byId('workEmployeeDrops').onclick=async event=>{const button=event.target.closest('[data-drop-user]');if(!button)return;target=Number(button.dataset.dropUser);byId('workEmployee').value=target;await loadQueue();await showTaskSource('queue');calendar.refetchEvents();};
root.addEventListener('dragstart',event=>{
const element=event.target.closest('[data-task-id]');if(!element)return;
taskDrag=taskFromElement(element);if(taskDrag)taskDrag.kind=event.target.closest('[data-task-kind]')?.dataset.taskKind||'reorder';if(!taskDrag){event.preventDefault();return;}
event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('application/x-bmc-task',String(taskDrag.id));event.dataTransfer.setData('text/plain',taskDrag.title);element.classList.add('is-dragging');
});
root.addEventListener('dragend',()=>{taskDrag=null;root.querySelectorAll('.is-dragging,.is-drag-over').forEach(el=>el.classList.remove('is-dragging','is-drag-over'));});
byId('workEmployeeDrops').addEventListener('dragover',event=>{if(!taskDrag||taskDrag.kind!=='reorder')return;const target=event.target.closest('[data-drop-user],[data-drop-unassign]');if(target){event.preventDefault();target.classList.add('is-drag-over');}});
byId('workEmployeeDrops').addEventListener('dragleave',event=>event.target.closest('.is-drag-over')?.classList.remove('is-drag-over'));
byId('workEmployeeDrops').addEventListener('drop',async event=>{if(!taskDrag||taskDrag.kind!=='reorder')return;event.preventDefault();event.stopPropagation();const button=event.target.closest('[data-drop-user],[data-drop-unassign]'),task={...taskDrag};if(!button)return;button.classList.remove('is-drag-over');if(button.hasAttribute('data-drop-unassign'))await unassignTask(task);else await assignTask(task,Number(button.dataset.dropUser));});
byId('workCalendar').addEventListener('dragover',event=>{if(!taskDrag||taskDrag.kind==='reorder'||(taskDrag.kind!=='slot'&&!canEditDates()))return;event.preventDefault();const {cell}=calendarTaskTarget(event);root.querySelectorAll('#workCalendar .is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));cell?.classList.add('is-drag-over');});
byId('workCalendar').addEventListener('drop',async event=>{if(!taskDrag)return;event.preventDefault();event.stopPropagation();const task={...taskDrag},{day,clock}=calendarTaskTarget(event);root.querySelectorAll('#workCalendar .is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));try{if(task.kind==='reorder')throw new Error('Træk knappen Start, Deadline eller Allokeret til kalenderen.');if(!day)throw new Error('Træk knappen til en dag i kalenderens uge- eller månedsvisning.');if(task.kind==='slot'){planTaskSlot(task,day,clock);return;}const instant=clock?(task.kind==='deadline'?new Date(day+'T'+clock).toISOString():day+'T'+clock+':00'):null;await setTaskDate(task,day,task.kind,instant);}catch(err){error(err);calendar.refetchEvents();}});
async function tab(requested) {
const aliases={day:'work',week:'work',calendar:'work',queue:'tasks',unassigned:'tasks',capacity:'team'};
const name=aliases[requested]||requested;currentView=['work','tasks','team'].includes(name)?name:'work';
if(requested==='queue')taskSource='queue';if(requested==='unassigned')taskSource='inbox';
if(new URLSearchParams(location.search).get('source')==='queue'||!metadata.permissions.some(p=>['planner.allocate','planner.admin'].includes(p)))taskSource='queue';
byId('workEmployeeToolbar').hidden=false;
root.classList.toggle('work-task-mode',currentView==='tasks');byId('workSourceFilter').querySelector('[data-task-source=inbox]').hidden=!metadata.permissions.some(p=>['planner.allocate','planner.admin'].includes(p));
document.querySelectorAll('[data-work-tab]').forEach(b=>{b.classList.toggle('active',b.dataset.workTab===currentView);b.setAttribute('aria-pressed',String(b.dataset.workTab===currentView));});
const daily=currentView!=='team';byId('workDaily').hidden=!daily;byId('workTaskBar').hidden=currentView!=='tasks';byId('workSourceFilter').hidden=currentView!=='tasks';byId('workTeam').hidden=daily;byId('workCapacity').hidden=daily;
if(!daily&&!byId('workTeam').src)byId('workTeam').src='/planner?embedded=1';
await showTaskSource(currentView==='tasks'?taskSource:'queue');
if(daily){const view=requested==='week'?'timeGridWeek':requested==='calendar'?'dayGridMonth':currentView==='tasks'?'timeGridWeek':innerWidth<800?'listDay':'timeGridDay';calendar.changeView(view);calendar.updateSize();calendar.refetchEvents();}
if(name==='team'){const start=new Date();start.setHours(0,0,0,0);const end=new Date(start);end.setDate(end.getDate()+7);const data=await api(`/capacity?starts_at=${encodeURIComponent(start.toISOString())}&ends_at=${encodeURIComponent(end.toISOString())}&user_id=${target}`);byId('workCapacity').innerHTML=data.items.map(r=>`<article class="card work-card"><h2 class="h5">${esc(r.name)}</h2><p>Normtid ${r.norm_configured?minutes(r.normal_minutes):'ikke opsat'} · Optaget ${minutes(r.busy_minutes)} · Ledig ${r.norm_configured?minutes(r.free_minutes):'ukendt'} · Kø ${minutes(r.queue_minutes)}</p><p>Overbelastning (prognose): ${r.norm_configured?minutes(r.overload_minutes):'kan ikke beregnes'} · Ufordelte estimater: ${r.unestimated_count}</p>${(r.deadline_risks||[]).map(w=>`<p class="text-danger"><a href="/sag/${w.sag_id}">#${w.sag_id}</a> · ${esc(({overdue:"Deadline overskredet",booking_after_deadline:"Arbejde planlagt efter deadline",unallocated_estimate:"Estimat mangler fordeling",insufficient_capacity:"Risiko: for lidt tid før deadline"})[w.kind]||w.kind)}</p>`).join('')}</article>`).join('');}
}
document.querySelectorAll('[data-work-tab]').forEach(b=>b.onclick=()=>{history.replaceState(null,'','?view='+b.dataset.workTab);tab(b.dataset.workTab).catch(error);});
async function init() {
metadata=await api('/metadata');byId('workAbsenceReport').hidden=!metadata.can_view_absence_report;renderEmployeeDrops();const requestedEmployee=Number(new URLSearchParams(location.search).get('user_id'));if(metadata.users.some(u=>u.id===requestedEmployee))target=requestedEmployee;const types=await api('/activity-types');activityTypes=types;byId('workEmployee').innerHTML=metadata.users.map(u=>`<option value="${u.id}" ${u.id===target?'selected':''}>${esc(u.name)}</option>`).join('');byId('workEmployee').onchange=async()=>{target=Number(byId('workEmployee').value);try{await loadQueue();calendar.refetchEvents();await tab(new URLSearchParams(location.search).get('view')||'day');}catch(e){error(e);}};const config=await api('/configuration');if(config.can_manage){byId('workSettings').hidden=false;byId('workSettings').onclick=()=>editSettings(config).catch(error);}
for(const select of root.querySelectorAll('select[name=user_id],select[name=user_ids]')){select.innerHTML=metadata.users.map(u=>`<option value="${u.id}" ${Number(u.id)===uid?'selected':''}>${esc(u.name||u.full_name||u.username)}</option>`).join('');}
byId('workAbsenceForm').elements.kind.innerHTML=types.filter(t=>t.active&&t.is_absence).map(t=>`<option value="${esc(t.code)}">${esc(t.label)}</option>`).join('');
calendar=new FullCalendar.Calendar(byId('workCalendar'),{locale:'da',firstDay:1,initialView:innerWidth<800?'listDay':'timeGridDay',height:'auto',scrollTime:'08:00:00',eventDurationEditable:false,headerToolbar:{left:'prev,next today',center:'title',right:'timeGridDay,timeGridWeek,dayGridMonth,listWeek'},buttonText:{today:'I dag',day:'Dag',week:'Uge',month:'Måned',list:'Liste'},editable:true,eventDidMount:reservationMount,dateClick:info=>eventDialog(null,info.date),events:async(info,success,failure)=>{try{const data=await api(`/schedule?starts_at=${encodeURIComponent(info.startStr)}&ends_at=${encodeURIComponent(info.endStr)}&user_id=${target}&include_unassigned=${currentView==='tasks'}`);const grouped=new Map();data.bookings.forEach(r=>{const key=r.event_id||'booking-'+r.id;if(!grouped.has(key))grouped.set(key,{...r,participants:r.participants||[]});if(!grouped.get(key).participants.includes(r.user_id))grouped.get(key).participants.push(r.user_id);});const events=[...grouped.values()].map(r=>reservationEvent(r,calendar.view.type));data.absences.forEach(r=>events.push(absenceEvent(r)));renderAbsences(data.absences);data.standalone_plans.forEach(r=>events.push({id:'quick-'+r.id,title:r.title,start:r.starts_at,end:r.ends_at,color:'#336699',editable:false}));(data.milestones||[]).forEach(r=>events.push({id:r.id,title:r.title,start:r.start,allDay:r.allDay||false,color:r.kind==='deadline'?'#b42318':'#475467',editable:['case_start','deadline'].includes(r.kind)&&canEditDates(),durationEditable:false,extendedProps:{caseUrl:'/sag/'+r.sag_id,milestone:r}}));(data.registered||[]).forEach(r=>events.push({id:'registered-'+r.id,title:'Registreret: '+r.title,start:r.starts_at,end:r.ends_at,color:'#7c3aed',editable:false,extendedProps:{caseUrl:r.sag_id?'/sag/'+r.sag_id:null}}));(data.external_busy||[]).forEach(r=>events.push({id:'external-'+r.id,title:'Optaget eksternt',start:r.starts_at,end:r.ends_at,color:'#94a3b8',editable:false}));success(events);}catch(e){failure(e);}},eventClick:async info=>{if(['case_start','deadline'].includes(info.event.extendedProps.milestone?.kind)){await editTaskDates(info.event.extendedProps.milestone.sag_id).catch(error);return;}if(info.event.extendedProps.caseUrl){location.assign(info.event.extendedProps.caseUrl);return;}if(info.event.extendedProps.booking)eventDialog(info.event.extendedProps.booking);else if(info.event.extendedProps.absence){absenceDialog(info.event.extendedProps.absence);}},eventDrop:async info=>{const marker=info.event.extendedProps.milestone;if(marker){try{await setTaskDate({id:marker.sag_id,updated_at:marker.case_updated_at},info.event.startStr.slice(0,10),marker.kind==='deadline'?'deadline':'start',info.event.allDay?null:info.event.startStr);}catch(err){info.revert();error(err);}return;}const r=info.event.extendedProps.booking;if(!r){info.revert();return;}try{await saveWithConflict('/events/'+r.event_id,{method:'PATCH',body:JSON.stringify({title:r.title,description:r.description,sag_id:r.sag_id,kind:r.kind||'work',user_ids:r.participants,version:r.event_version,starts_at:info.event.start.toISOString(),ends_at:info.event.end.toISOString()})});calendar.refetchEvents();}catch(e){info.revert();error(e);}},eventResize:()=>calendar.refetchEvents()});
calendar.render();byId('workCreate').disabled=!metadata.editable_user_ids.length;byId('workAbsence').disabled=!metadata.editable_user_ids.some(id=>metadata.permissions.some(p=>p==='planner.admin'||p===(id===uid?'planner.absence_own':'planner.absence_team')));await loadQueue();await tab(new URLSearchParams(location.search).get('view')||'day');
if(new URLSearchParams(location.search).has('next'))await next();const caseId=Number(new URLSearchParams(location.search).get('case_id'));if(caseId>0)await editCasePlan(caseId);setInterval(()=>{if(document.hidden||taskDrag||taskBusy||root.querySelector('dialog[open]'))return;loadQueue().catch(()=>{});calendar.refetchEvents();if(currentView==='team')tab('team').catch(()=>{});},30000);
}
init().catch(error);
})();

21
static/vendor/flatpickr/LICENSE.md vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 Gregory Petrosyan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

71
static/vendor/flatpickr/da.js vendored Normal file
View File

@ -0,0 +1,71 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.da = {}));
}(this, (function (exports) { 'use strict';
var fp = typeof window !== "undefined" && window.flatpickr !== undefined
? window.flatpickr
: {
l10ns: {},
};
var Danish = {
weekdays: {
shorthand: ["søn", "man", "tir", "ons", "tors", "fre", "lør"],
longhand: [
"søndag",
"mandag",
"tirsdag",
"onsdag",
"torsdag",
"fredag",
"lørdag",
],
},
months: {
shorthand: [
"jan",
"feb",
"mar",
"apr",
"maj",
"jun",
"jul",
"aug",
"sep",
"okt",
"nov",
"dec",
],
longhand: [
"januar",
"februar",
"marts",
"april",
"maj",
"juni",
"juli",
"august",
"september",
"oktober",
"november",
"december",
],
},
ordinal: function () {
return ".";
},
firstDayOfWeek: 1,
rangeSeparator: " til ",
weekAbbreviation: "uge",
time_24hr: true,
};
fp.l10ns.da = Danish;
var da = fp.l10ns;
exports.Danish = Danish;
exports.default = da;
Object.defineProperty(exports, '__esModule', { value: true });
})));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,63 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const source = fs.readFileSync('static/js/case-slot-resize.js', 'utf8');
function setup(responses = [{ok: true}]) {
const handlers = {}, calls = [], messages = [];
let reloads = 0;
const label = {textContent: ''};
const button = {
dataset: {allocationId: '42', version: '3', start: '2026-10-21T06:00:00Z', end: '2026-10-21T06:30:00Z'},
closest: selector => selector === '.case-slot-resize' ? button : {querySelector: () => label},
classList: {add() {}, remove() {}}, setPointerCapture() {}, hasPointerCapture: () => true, releasePointerCapture() {},
};
vm.runInNewContext(source, {
document: {getElementById: () => ({addEventListener: (type, fn) => {handlers[type] = fn;}})},
window: {confirm: () => true, showCaseFeedback: (...args) => messages.push(args), loadEmbeddedCasePlanner: async () => {reloads++;}},
fetch: async (url, options) => {calls.push({url, payload: JSON.parse(options.body)});const response = responses.shift();return {...response, json: async () => response.body || {}};},
});
const event = values => ({target: button, pointerId: 1, button: 0, clientY: 100, preventDefault() {}, stopPropagation() {}, ...values});
return {handlers, calls, messages, label, event, reloads: () => reloads};
}
const settle = () => new Promise(resolve => setImmediate(resolve));
test('drag changes end time in 15 minute steps and sends current version', async () => {
const s = setup();
s.handlers.pointerdown(s.event());
s.handlers.pointermove(s.event({clientY: 124}));
assert.match(s.label.textContent, /60 min/);
s.handlers.pointerup(s.event());
await settle();
assert.deepEqual(s.calls, [{url: '/api/v1/planner/allocations/42', payload: {version: 3, ends_at: '2026-10-21T07:00:00.000Z'}}]);
assert.equal(s.reloads(), 1);
});
test('cancel and an unchanged drag do not write', async () => {
const s = setup();
s.handlers.pointerdown(s.event());
s.handlers.pointermove(s.event({clientY: 124}));
s.handlers.pointercancel(s.event());
assert.match(s.label.textContent, /30 min/);
s.handlers.pointerdown(s.event());
s.handlers.pointerup(s.event());
await settle();
assert.equal(s.calls.length, 0);
});
test('keyboard clamps duration to 15 minutes', async () => {
const s = setup();
s.handlers.keydown(s.event({key: 'ArrowUp'}));
await settle();
assert.equal(s.calls[0].payload.ends_at, '2026-10-21T06:15:00.000Z');
});
test('overlap requires confirmation before retry; stale version never retries', async () => {
const overlap = setup([{ok: false, status: 409, body: {detail: {message: 'Overlap', conflict_status: 'overlap'}}}, {ok: true}]);
overlap.handlers.keydown(overlap.event({key: 'ArrowDown'}));
await settle();
assert.equal(overlap.calls.length, 2);
assert.equal(overlap.calls[1].payload.confirm_conflicts, true);
const stale = setup([{ok: false, status: 409, body: {detail: {message: 'Ændret af en anden bruger', current: {version: 4}}}}]);
stale.handlers.keydown(stale.event({key: 'ArrowDown'}));
await settle();
assert.equal(stale.calls.length, 1);
assert.equal(stale.messages[0][0], 'Ændret af en anden bruger');
assert.equal(stale.reloads(), 1);
});

View File

@ -0,0 +1,42 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const source = fs.readFileSync('static/js/case-week-calendar.js', 'utf8');
function render(items) {
const host = {dataset: {}, style: {setProperty(name,value) {this[name]=value;}}, classList: {add() {}}, addEventListener() {}};
const window = {};
vm.runInNewContext(source, {window, document: {getElementById: () => ({textContent:''})}});
window.renderCaseWeekCalendar(host, new Date(2026,9,19,12), items);
return host;
}
function item(start,end) {return {id:1,version:2,starts_at:start,ends_at:end,user_name:'Test'};}
test('empty week defaults to five weekdays and 07–17', () => {
const host = render([]);
assert.equal(host.style['--week-days'],5);
assert.equal(host.dataset.firstHour,'7');
assert.equal(host.dataset.lastHour,'17');
assert.doesNotMatch(host.innerHTML,/data-date="2026-10-24"/);
});
test('early and late bookings extend the visible hours', () => {
const host = render([item('2026-10-20T05:30:00','2026-10-20T18:15:00')]);
assert.equal(host.dataset.firstHour,'5');
assert.equal(host.dataset.lastHour,'19');
assert.match(host.innerHTML,/data-allocation-id="1"/);
});
test('only occupied weekend days appear; other weeks do not extend range', () => {
const host = render([item('2026-10-25T10:00:00','2026-10-25T11:00:00'),item('2026-11-01T01:00:00','2026-11-01T23:00:00')]);
assert.equal(host.style['--week-days'],6);
assert.match(host.innerHTML,/data-date="2026-10-25"/);
assert.doesNotMatch(host.innerHTML,/data-date="2026-10-24"/);
assert.equal(host.dataset.firstHour,'7');
assert.equal(host.dataset.lastHour,'17');
});
test('overnight booking remains visible on both days', () => {
const host = render([item('2026-10-23T23:30:00','2026-10-24T01:00:00')]);
assert.equal(host.style['--week-days'],6);
assert.equal(host.dataset.firstHour,'0');
assert.equal(host.dataset.lastHour,'24');
assert.equal((host.innerHTML.match(/class="case-planner-calendar-item v5-time-event"/g)||[]).length,2);
assert.equal((host.innerHTML.match(/data-allocation-id="1"/g)||[]).length,1);
});

View File

@ -0,0 +1,43 @@
/* Exercise report rendering and CSV export using the production script. */
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const elements=new Map(),requests=[];
function element(id){if(!elements.has(id))elements.set(id,{innerHTML:'',textContent:'',hidden:false,disabled:false,dataset:{},value:'',insertAdjacentHTML(_,html){this.innerHTML+=html;}});return elements.get(id);}
const form=element('absenceFilters');form.elements={start_date:element('absenceFrom'),end_date:element('absenceTo'),user_id:element('absenceEmployee')};
const item={id:1,kind:'vacation',name:'=Private spreadsheet formula',kind_label:'Ferie',starts_at:'2026-10-15T06:00:00Z',ends_at:'2026-10-16T14:00:00Z',norm_configured:true,planned_minutes:900,planned_days:2,actual_minutes:450,registered:true};
const result={employees:[{id:1,name:'One'}],summary:[{user_id:1,name:'One',norm_configured:true,planned_minutes:900,planned_days:2,actual_minutes:450}],items:[item],total:1,totals:{planned_minutes:900,planned_days:2,actual_minutes:450}};
result.type_summary=[{kind:'vacation',kind_label:'Ferie',periods:1,norm_configured:true,planned_minutes:900,planned_days:2,actual_minutes:450,employees:result.summary}];
let download=null;
const context={window:{},Intl,Date,Number,String,Promise,URLSearchParams,Blob,
FormData:class{constructor(form){this.values=Object.entries(form.elements).map(([key,input])=>[key,input.value]);}[Symbol.iterator](){return this.values[Symbol.iterator]();}},
document:{getElementById:element,createElement:()=>({click(){}})},
fetch:async path=>{requests.push(path);return{ok:true,json:async()=>result};},
URL:{createObjectURL:blob=>{download=blob;return 'blob:report';},revokeObjectURL(){}},setTimeout:()=>{},
};
vm.createContext(context);vm.runInContext(fs.readFileSync('static/js/absence-report.js','utf8'),context);
(async()=>{
await new Promise(resolve=>setImmediate(resolve));
assert.match(element('absenceRows').innerHTML,/Tid registreret/);
assert.match(element('absenceRows').innerHTML,/scope="rowgroup" colspan="8">Ferie/);
assert.match(element('absenceSummary').innerHTML,/Ferie/);
assert.match(element('absenceSummary').innerHTML,/One/);
assert.equal(new URLSearchParams(requests[0].split('?')[1]).get('group_by'),'type');
assert.match(element('absenceTotals').innerHTML,/15 t/);
assert.match(element('absenceTotals').innerHTML,/7,5 t/);
assert.match(element('absenceRows').innerHTML,/<td>2<\/td>/);
assert.match(element('absencePage').textContent,/2 arbejdsdage i alt/);
assert.equal(element('absenceExport').disabled,false);
await element('absenceExport').onclick();
const csv=await download.text();
assert.match(csv,/"Ferie";"'=Private spreadsheet formula"/);
assert.equal(new URLSearchParams(requests[1].split('?')[1]).get('group_by'),'type');
assert.match(csv,/"Arbejdsdage"/);
assert.match(csv,/"2";"15";"7,5";"Tid registreret"/);
assert.equal(requests.length,2);
assert.equal(new URLSearchParams(requests[1].split('?')[1]).get('limit'),'500');
assert.equal(new URLSearchParams(requests[0].split('?')[1]).get('start_date'),new URLSearchParams(requests[1].split('?')[1]).get('start_date'));
context.fetch=async()=>({ok:false,json:async()=>({detail:'Ingen adgang'})});
await form.onsubmit({preventDefault(){}});await new Promise(resolve=>setImmediate(resolve));
assert.equal(element('absenceExport').disabled,true);
assert.equal(element('absenceRows').innerHTML,'');assert.equal(element('absenceError').textContent,'Ingen adgang');
console.log('Absence report UI: rendering, separate actual time, CSV quoting, filters and access failures OK');
})().catch(err=>{console.error(err);process.exitCode=1;});

View File

@ -0,0 +1,178 @@
"""Regression coverage for Marketplace period amounts; no external writes."""
from copy import deepcopy
from decimal import Decimal
import json
import pytest
from fastapi import HTTPException
from app.modules.also.backend.billing import period_amount_line, check_period_amounts, _period_text
from app.modules.orders.backend.router import OrdreLineInput
from app.products.backend.economic_documents import line_snapshot
CASES = [
(116, '67.08', '7028.25', '01.08.2026 - 29.08.2026', '28 af 31'),
(116, '67.08', '753.03', '29.08.2026 - 01.09.2026', '3 af 31'),
(108, '67.08', '6543.55', '01.08.2026 - 29.08.2026', '28 af 31'),
(108, '67.08', '701.09', '29.08.2026 - 01.09.2026', '3 af 31'),
(1, '27.90', '17.10', '13.08.2026 - 01.09.2026', '19 af 31'),
(1, '50.28', '19.46', '20.08.2026 - 01.09.2026', '12 af 31'),
]
def source(index=0):
quantity, price, amount, interval, _ = CASES[index]
return dict(line_key=f'also:{index+1}', source_type='also_cloud', source_id=index+1,
product_id=12, description='Microsoft abonnement\nPeriode: ' + interval,
quantity=quantity, unit_price=price, amount=amount,
discount_percentage=0, currency='DKK', meta={'billing_period': interval})
def test_norva_period_amounts_survive_request_model_and_catalog_snapshot():
total = Decimal(0)
product = dict(id=12, name='Microsoft abonnement', economic_product_number='MS-365',
economic_connection_id=1, economic_unit_number=1, status='active')
for index, (_, _, amount, _, days) in enumerate(CASES):
old = source(index)
before = deepcopy(old)
line = period_amount_line(old)
assert old == before
assert period_amount_line(line) == line
assert days + ' dage.' in line['description']
assert 'Reference:' not in line['description']
request_line = OrdreLineInput(**line).model_dump()
check_period_amounts([request_line], [old])
snapshot = line_snapshot(request_line, product, 1, 'DKK')
assert snapshot['quantity'] == '1.00'
assert snapshot['unit_price'] == amount
assert snapshot['net_total'] == amount
total += Decimal(snapshot['net_total'])
assert total == Decimal('15062.48')
def test_exact_customer_text_and_no_reference():
old = source()
old['description'] += '\nReference: sag-xx11.'
line = period_amount_line(old)
assert line['description'] == ('Microsoft abonnement\n116 licenser á 67,08 kr./måned. '
'Periode: 01.08.–28.08.2026, 28 af 31 dage.')
@pytest.mark.parametrize('interval,expected', [
('01.02.2024 - 01.03.2024', '29 af 29 dage.'),
('01.02.2026 - 01.03.2026', '28 af 28 dage.'),
('31.12.2026 - 02.01.2027', '31.12.2026–01.01.2027, 2 dage.'),
('31.08.2026 - 02.09.2026', '31.08.–01.09.2026, 2 dage.'),
])
def test_exclusive_period_end(interval, expected):
assert expected in _period_text(interval)
@pytest.mark.parametrize('changes', [dict(amount=None), dict(amount='NaN'), dict(amount='Infinity'),
dict(amount='-1'), dict(quantity=0), dict(quantity=None),
dict(unit_price=None), dict(discount_percentage=10)])
def test_invalid_source_blocks_instead_of_silent_fallback(changes):
with pytest.raises(HTTPException):
period_amount_line(dict(source(), **changes))
def test_zero_is_preserved():
line = period_amount_line(dict(source(), amount=0, unit_price=0))
assert line['unit_price'] == line['amount'] == 0
@pytest.mark.parametrize('changes', [dict(quantity=116), dict(unit_price=67.08),
dict(product_id=99), dict(currency='EUR'),
dict(discount_percentage=1), dict(source_type='manual'),
dict(description='changed')])
def test_stale_or_modified_export_is_blocked(changes):
line = dict(period_amount_line(source()), **changes)
with pytest.raises(HTTPException):
check_period_amounts([line], [source()])
def test_unsaved_also_line_cannot_bypass_approved_amount():
with pytest.raises(HTTPException):
check_period_amounts([period_amount_line(source())], [])
def test_other_line_types_are_unchanged():
line = dict(source(), source_type='manual')
assert period_amount_line(line) == line
check_period_amounts([line], [])
def test_repricing_skips_approved_also_period_amounts(monkeypatch):
from app.products.backend import economic_pricing
monkeypatch.setattr(economic_pricing, 'query', lambda *args, **kwargs: [])
assert economic_pricing.proposals({'id': 1, 'customer_id': 1, 'lines_json': [period_amount_line(source())]})['changes'] == []
@pytest.mark.parametrize('raw_line', [{}, {'Charge': '7028.25', 'Unit Price': '67.08'}])
def test_approval_stores_period_price_with_source_identity(monkeypatch, raw_line):
from app.modules.also.backend import service
raw = dict(id=1, import_job_id=5, matched_customer_id=8, matched_product_id=12,
matched_customer_name='Testkunde', product_name='Microsoft abonnement',
currency='DKK', billing_start='2026-08-01', billable_parameters=116,
unit_price=Decimal('67.08'), total_price=Decimal('7028.25'),
charge_interval='01.08.2026 - 29.08.2026', raw_line_json=raw_line)
writes = []
def query(sql, params=()):
if 'SELECT' in sql and 'FROM also_import_lines l' in sql:
return [raw]
writes.append((sql, params))
return []
def single(sql, params=()):
if 'INSERT INTO ordre_drafts' in sql:
writes.append((sql, params))
return {'id': 44}
return None
monkeypatch.setattr(service.AlsoService, '_assert_enabled', lambda self: None)
monkeypatch.setattr(service, 'execute_query', query)
monkeypatch.setattr(service, 'execute_query_single', single)
monkeypatch.setattr(service, 'with_invoice_fee', lambda lines, customer: lines)
result = service.AlsoService().approve_lines_to_drafts(5, [1], 3)
insert = next(params for sql, params in writes if 'INSERT INTO ordre_drafts' in sql)
line = json.loads(insert[2])[0]
assert line['quantity'] == 1
assert line['unit_price'] == line['amount'] == 7028.25
assert line['source_id'] == 1
assert line['meta']['also_license_quantity'] == '116'
assert line['meta']['also_monthly_unit_price'] == '67.08'
assert result['approved_lines'] == 1
def test_duplicate_source_line_cannot_double_the_charge():
line = period_amount_line(source())
with pytest.raises(HTTPException):
check_period_amounts([line, line], [source()])
def test_existing_pending_draft_is_presented_correctly_without_database_write(monkeypatch):
import asyncio
from app.core import database
from app.modules.orders.backend.router import get_ordre_draft
old = source()
row = dict(id=44, sync_status='pending', lines_json=[old], export_status_json={})
monkeypatch.setattr(database, 'execute_query_single', lambda *args: deepcopy(row))
def no_write(*args, **kwargs):
raise AssertionError('Reading a pending draft must not write to the database')
monkeypatch.setattr(database, 'execute_query', no_write)
result = asyncio.run(get_ordre_draft(44, None))
assert result['lines_json'][0]['quantity'] == 1
assert result['lines_json'][0]['unit_price'] == 7028.25
assert row['lines_json'][0] == old
@pytest.mark.parametrize('state', [dict(sync_status='exported', economic_order_number='123'),
dict(sync_status='uncertain'),
dict(sync_status='pending', export_idempotency_key='attempt-123')])
def test_exported_or_uncertain_history_is_not_rewritten(monkeypatch, state):
import asyncio
from app.core import database
from app.modules.orders.backend.router import get_ordre_draft
row = dict(id=44, lines_json=[source()], export_status_json={}, **state)
monkeypatch.setattr(database, 'execute_query_single', lambda *args: deepcopy(row))
result = asyncio.run(get_ordre_draft(44, None))
assert result['lines_json'] == row['lines_json']

View File

@ -0,0 +1,146 @@
"""Machine linking tests. Database verification is opt-in and always rolls back."""
import asyncio
from contextlib import contextmanager
import json
import os
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from uuid import uuid4
from fastapi import HTTPException
from app.services import anydesk_links as links
class MachineIdentityTests(unittest.TestCase):
def test_only_customer_end_is_used(self):
self.assertEqual(links.customer_machine_id({'to_id': '123 456-789', 'from_id': '999'}), '123456789')
self.assertIsNone(links.customer_machine_id({'from_id': '123456789', 'remote_id': '123456789'}))
self.assertIsNone(links.customer_machine_id({'to_id': 'computer123'}))
self.assertEqual(links.customer_machine_id('{"to_id":"123456789"}'), '123456789')
def test_missing_customer_id_cannot_create_mapping(self):
with patch.object(links, 'execute_query', side_effect=[
[{'device_info': {'to_id': '123456789'}}], []
]):
with self.assertRaises(HTTPException) as error:
links.remember_machine(1, None, None)
self.assertEqual(error.exception.status_code, 409)
def test_missing_customer_machine_id_does_not_use_technician(self):
with patch.object(links, 'execute_query', return_value=[{'device_info': {'from_id': '999'}}]):
with self.assertRaises(HTTPException):
links.remember_machine(1, 2, 3)
def test_local_bridge_applies_mapping_but_never_during_dry_run(self):
from app.services import anydesk as service
for dry_run in (False, True):
with self.subTest(dry_run=dry_run):
client = AsyncMock()
client.__aenter__.return_value = client
client.get.return_value = Mock(json=Mock(return_value=[]))
with patch.object(service.httpx, 'AsyncClient', return_value=client), \
patch.object(service, 'apply_machine_links') as apply:
result = asyncio.run(service.AnyDeskService().fetch_sessions_from_local_endpoint(
'http://test.invalid/sessions', dry_run=dry_run))
self.assertEqual(result['errors'], [])
if dry_run:
apply.assert_not_called()
else:
apply.assert_called_once_with()
@unittest.skipUnless(os.environ.get('BMC_ANYDESK_DB_TESTS') == '1', 'Opt-in rollback-only database test')
class MachineDatabaseTests(unittest.TestCase):
def test_manual_registration_backfills_and_next_import_remembers(self):
import psycopg2
from psycopg2.extras import RealDictCursor, Json
from app.core.config import settings
from app.routers import anydesk as router
from app.services import anydesk as service
conn = psycopg2.connect(settings.DATABASE_URL)
def query(sql, params=None):
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql, params)
return list(cursor.fetchall()) if cursor.description else cursor.rowcount
@contextmanager
def transaction():
yield conn
token = uuid4().hex
machine = str(uuid4().int)[:24]
tech = str(uuid4().int)[:24]
try:
pairs = query('''SELECT cc.customer_id,cc.contact_id FROM contact_companies cc
JOIN customers c ON c.id=cc.customer_id AND c.is_active=TRUE
JOIN contacts p ON p.id=cc.contact_id AND p.is_active=TRUE LIMIT 1''')
self.assertTrue(pairs, 'Requires an existing active customer/contact pair')
customer = pairs[0]['customer_id']
contact = query("INSERT INTO contacts(first_name,last_name,is_active) VALUES('AnyDesk test',%s,TRUE) RETURNING id", (token,))[0]['id']
query('INSERT INTO contact_companies(contact_id,customer_id) VALUES(%s,%s)', (contact, customer))
other = query('SELECT id FROM customers WHERE is_active=TRUE AND id<>%s LIMIT 1', (customer,))[0]['id']
def session(label, to_id=machine, cust=None, person=None):
return query('''INSERT INTO anydesk_sessions
(anydesk_session_id,device_info,customer_id,contact_id,status,notes)
VALUES(%s,%s,%s,%s,'completed','keep this note') RETURNING id''',
(token+label, Json({'to_id': to_id, 'from_id': tech}), cust, person))[0]['id']
selected = session('selected')
previous = session('previous')
conflict = session('conflict', cust=other)
technician = session('technician', to_id=tech)
no_customer_end = session('missing', to_id=None)
with patch.object(links, 'execute_query', query), patch.object(router, 'execute_query', query), \
patch.object(router, 'atomic_transaction', transaction), patch.object(service, 'execute_query', query):
response = asyncio.run(router.link_session(selected, customer_id=customer, contact_id=contact,
remember_machine_link=True))
result = json.loads(response.body)
self.assertTrue(result['remembered'])
self.assertEqual(result['linked_sessions'], 1)
hardware = query('SELECT id,asset_type,model,current_owner_customer_id FROM hardware_assets WHERE anydesk_id=%s', (machine,))
self.assertEqual(len(hardware), 1)
self.assertEqual((hardware[0]['asset_type'], hardware[0]['model']), ('pc', 'PC'))
hardware_id = hardware[0]['id']
self.assertTrue(query('SELECT 1 FROM hardware_contacts WHERE hardware_id=%s AND contact_id=%s', (hardware_id, contact)))
asyncio.run(router.link_session(selected, customer_id=customer, contact_id=contact, remember_machine_link=True))
self.assertEqual(len(query('SELECT id FROM hardware_assets WHERE anydesk_id=%s', (machine,))), 1)
saved = query('SELECT customer_id,contact_id,notes,sag_id FROM anydesk_sessions WHERE id=%s', (previous,))[0]
self.assertEqual((saved['customer_id'], saved['contact_id']), (customer, contact))
self.assertEqual(saved['notes'], 'keep this note')
self.assertIsNone(saved['sag_id'])
self.assertEqual(query('SELECT customer_id FROM anydesk_sessions WHERE id=%s', (conflict,))[0]['customer_id'], other)
for row_id in (technician, no_customer_end):
self.assertIsNone(query('SELECT customer_id FROM anydesk_sessions WHERE id=%s', (row_id,))[0]['customer_id'])
api = service.AnyDeskService()
api._api_call = AsyncMock(return_value={'list': [
{'sid': token+'future', 'from': {'cid': tech}, 'to': {'cid': machine},
'start-time': int(time.time()), 'duration': 120}
]})
imported = asyncio.run(api.fetch_sessions_from_api())
self.assertEqual(imported['errors'], [])
row = query('SELECT customer_id,contact_id FROM anydesk_sessions WHERE anydesk_session_id=%s', (token+'future',))[0]
self.assertEqual((row['customer_id'], row['contact_id']), (customer, contact))
self.assertEqual(links.apply_machine_links(machine), 0)
with self.assertRaises(HTTPException):
links.remember_machine(selected, other, contact)
# Reuse the contact's one unconfigured computer instead of duplicating it.
blank = query("INSERT INTO hardware_assets(asset_type,model,current_owner_type,current_owner_customer_id) VALUES('pc','Existing PC','customer',%s) RETURNING id", (customer,))[0]['id']
query("INSERT INTO hardware_contacts(hardware_id,contact_id) VALUES(%s,%s)", (blank, contact))
new_machine = str(uuid4().int)[:24]
new_session = session('blank', to_id=new_machine)
links.remember_machine(new_session, customer, contact)
self.assertEqual(query('SELECT anydesk_id,model FROM hardware_assets WHERE id=%s', (blank,))[0],
{'anydesk_id': new_machine, 'model': 'Existing PC'})
self.assertEqual(query('SELECT hardware_asset_id FROM anydesk_sessions WHERE id=%s', (new_session,))[0]['hardware_asset_id'], blank)
# Duplicate machine IDs must never be resolved by choosing an arbitrary PC.
query("INSERT INTO hardware_assets(asset_type,model,current_owner_customer_id,anydesk_id) VALUES('pc','Duplicate',%s,%s)", (customer, machine))
duplicate_session = session('duplicate')
self.assertEqual(links.apply_machine_links(machine), 0)
self.assertIsNone(query('SELECT hardware_asset_id FROM anydesk_sessions WHERE id=%s', (duplicate_session,))[0]['hardware_asset_id'])
with self.assertRaises(HTTPException):
links.remember_machine(duplicate_session, customer, contact)
finally:
conn.rollback()
conn.close()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,81 @@
"""Session history keeps its string timestamp API while accepting DB datetimes."""
import asyncio
from datetime import datetime, timezone
import pytest
from app.models.schemas import AnyDeskSessionDetail
from app.routers import anydesk
from app.services import anydesk as service
@pytest.mark.parametrize('ended', [None, datetime(2026, 10, 7, 8, 0, tzinfo=timezone.utc)])
def test_contact_history_serializes_database_timestamps(monkeypatch, ended):
timestamp = datetime(2026, 10, 7, 7, 29, tzinfo=timezone.utc)
row = dict(id=1, anydesk_session_id='history-test', customer_id=18, contact_id=1565,
status='completed' if ended else 'active', started_at=timestamp, ended_at=ended,
created_at=timestamp, updated_at=timestamp, duration_minutes=26)
def query(sql, params):
assert params[0] == 1565
return [{'total': 1}] if 'COUNT(*)' in sql else [row]
monkeypatch.setattr(service, 'execute_query', query)
result = asyncio.run(anydesk.get_session_history(contact_id=1565))
payload = result.model_dump(mode='json')
assert payload['total'] == 1
actual = payload['sessions'][0]
for key in ('started_at', 'created_at', 'updated_at'):
assert actual[key] == timestamp.isoformat()
assert actual['ended_at'] == (ended.isoformat() if ended else None)
assert actual['contact_id'] == 1565
def test_existing_string_timestamps_are_preserved():
row = AnyDeskSessionDetail(id=1, anydesk_session_id='legacy', customer_id=18,
status='completed', started_at='2026-10-07T07:29:00Z',
created_at='2026-10-07T07:29:00Z')
assert row.started_at == '2026-10-07T07:29:00Z'
def test_link_case_from_contact_table_changes_only_selected_session(monkeypatch):
from contextlib import nullcontext
import json
updates = []
def query(sql, params):
if 'FROM sag_sager' in sql:
return [{'id': 123, 'customer_id': 21}]
if 'SELECT customer_id FROM anydesk_sessions' in sql:
return [{'customer_id': 21}]
updates.append((sql, params))
return []
monkeypatch.setattr(anydesk, 'execute_query', query)
monkeypatch.setattr(anydesk, 'atomic_transaction', nullcontext)
response = asyncio.run(anydesk.link_session(551, sag_id=123, require_same_customer=True))
assert json.loads(response.body)['ok']
assert len(updates) == 1
sql, params = updates[0]
assert 'sag_id = %s' in sql
assert 'WHERE id = %s' in sql
assert params == (123, 551)
assert 'customer_id =' not in sql
assert 'contact_id =' not in sql
@pytest.mark.parametrize('case_rows,session_rows,status', [
([], [{'customer_id':21}], 404),
([{'id':123,'customer_id':99}], [{'customer_id':21}], 409),
([{'id':123,'customer_id':21}], [], 404),
])
def test_case_picker_rejects_missing_or_other_customer_cases(monkeypatch, case_rows, session_rows, status):
from contextlib import nullcontext
from fastapi import HTTPException
def query(sql, params):
if 'FROM sag_sager' in sql:
return case_rows
if 'SELECT customer_id FROM anydesk_sessions' in sql:
return session_rows
raise AssertionError('Must not change session on rejected case selection')
monkeypatch.setattr(anydesk, 'execute_query', query)
monkeypatch.setattr(anydesk, 'atomic_transaction', nullcontext)
with pytest.raises(HTTPException) as error:
asyncio.run(anydesk.link_session(551, sag_id=123, require_same_customer=True))
assert error.value.status_code == status

View File

@ -0,0 +1,79 @@
"""Asset usage is independent of hardware type, ownership and rental lifecycle."""
import asyncio
import os
import unittest
from unittest.mock import patch
from uuid import uuid4
from fastapi import HTTPException
from app.modules.hardware.backend import router as hardware
class AssetCategoryTests(unittest.TestCase):
def test_only_bmc_assets_can_be_classified(self):
for category in ('rental', 'internal'):
hardware._validate_bmc_asset_category(category, 'bmc')
with self.assertRaises(HTTPException):
hardware._validate_bmc_asset_category(category, 'customer')
hardware._validate_bmc_asset_category(None, 'customer')
hardware._validate_bmc_asset_category(None, 'bmc')
with self.assertRaises(HTTPException):
hardware._validate_bmc_asset_category('pc', 'bmc')
def test_existing_create_payload_without_category_still_works(self):
calls = []
def query(sql, params):
calls.append((sql, params))
self.assertEqual(sql.count('%s'), len(params))
return [{'id': 1, 'brand': None, 'model': 'Legacy PC'}]
with patch.object(hardware, 'execute_query', query):
result = asyncio.run(hardware.create_hardware({'asset_type':'pc', 'model':'Legacy PC'}))
self.assertEqual(result['id'], 1)
self.assertIsNone(calls[0][1][-1])
@unittest.skipUnless(os.environ.get('BMC_HARDWARE_CATEGORY_DB_TESTS') == '1', 'Opt-in rollback-only DB test')
class AssetCategoryDatabaseTests(unittest.TestCase):
def test_create_edit_filter_and_customer_separation(self):
import psycopg2
from psycopg2.extras import RealDictCursor
from app.core.config import settings
from app.modules.rentals.backend import router as rentals
conn = psycopg2.connect(settings.DATABASE_URL)
def query(sql, params=None):
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql, params)
return list(cursor.fetchall()) if cursor.description else cursor.rowcount
token = uuid4().hex
try:
with patch.object(hardware, 'execute_query', query), patch.object(rentals, 'execute_query', query):
created = asyncio.run(hardware.create_hardware(dict(asset_type='pc',model='Asset test '+token,
current_owner_type='bmc',bmc_asset_category='rental')))
asset_id = created['id']
self.assertEqual((created['asset_type'],created['bmc_asset_category']), ('pc','rental'))
def listed(category):
return asyncio.run(rentals.list_assets(status='all', customer_id=None,
only_rental_enabled=False, bmc_asset_category=category))
self.assertIn(asset_id, [row['id'] for row in listed('rental')])
edited = asyncio.run(hardware.update_hardware(asset_id, {'bmc_asset_category':'internal'}))
self.assertEqual((edited['asset_type'],edited['current_owner_type'],edited['bmc_asset_category']), ('pc','bmc','internal'))
self.assertNotIn(asset_id, [row['id'] for row in listed('rental')])
self.assertIn(asset_id, [row['id'] for row in listed('internal')])
with self.assertRaises(HTTPException):
asyncio.run(hardware.update_hardware(asset_id, {'bmc_asset_category':'other'}))
cleared = asyncio.run(hardware.update_hardware(asset_id, {'bmc_asset_category':None}))
self.assertIsNone(cleared['bmc_asset_category'])
self.assertIn(asset_id, [row['id'] for row in listed('unassigned')])
customer = query("SELECT id FROM customers WHERE is_active=TRUE LIMIT 1")[0]['id']
customer_pc = asyncio.run(hardware.create_hardware(dict(asset_type='pc',model='Customer test '+token,
current_owner_type='customer',current_owner_customer_id=customer)))
self.assertNotIn(customer_pc['id'], [row['id'] for row in listed(None)])
with self.assertRaises(HTTPException):
asyncio.run(hardware.update_hardware(customer_pc['id'], {'bmc_asset_category':'internal'}))
finally:
conn.rollback()
conn.close()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,26 @@
"""The contact card must not present all company equipment as personal hardware."""
import asyncio
from unittest.mock import Mock
from app.modules.hardware.backend import router
def test_direct_only_excludes_company_equipment(monkeypatch):
query = Mock(side_effect=[[{'id': 1, 'model': 'Personal PC'}], [{'id': 2, 'model': 'Other employee PC'}]])
monkeypatch.setattr(router, 'execute_query', query)
rows = asyncio.run(router.list_hardware_by_contact(1565, direct_only=True))
assert rows == [{'id': 1, 'model': 'Personal PC'}]
assert query.call_count == 1
assert query.call_args.args[1] == (1565,)
def test_no_direct_hardware_stays_empty(monkeypatch):
monkeypatch.setattr(router, 'execute_query', Mock(return_value=[]))
assert asyncio.run(router.list_hardware_by_contact(1565, direct_only=True)) == []
def test_existing_case_callers_keep_company_hardware(monkeypatch):
monkeypatch.setattr(router, 'execute_query', Mock(side_effect=[
[{'id': 1}], [{'id': 1}, {'id': 2}]
]))
assert asyncio.run(router.list_hardware_by_contact(1565)) == [{'id': 1}, {'id': 2}]

View File

@ -0,0 +1,192 @@
"""Reviewed hardware intake: source quantities, serials, provenance and retries."""
import asyncio
from contextlib import contextmanager
from decimal import Decimal
import os
import unittest
from unittest.mock import AsyncMock, Mock, patch
from uuid import uuid4
from fastapi import HTTPException, UploadFile
from io import BytesIO
from app.modules.hardware.backend import intake
class IntakeTests(unittest.TestCase):
def test_lines_keep_zero_and_do_not_guess_missing_values(self):
result = intake._lines([{'id':7,'line_number':1,'description':'PC','quantity':2,'unit_price':0}])[0]
self.assertEqual(result['key'], '7')
self.assertEqual(result['quantity'], '2')
self.assertEqual(result['unit_price'], '0')
self.assertIsNone(intake._lines([{'description':'PC'}])[0]['quantity'])
def test_file_line_keys_remain_stable_after_extraction_gets_database_ids(self):
before = intake._lines([{'line_number':2,'description':'PC'}], use_line_numbers=True)[0]
after = intake._lines([{'id':900,'line_number':2,'description':'PC'}], use_line_numbers=True)[0]
self.assertEqual(before['key'], after['key'])
def test_duplicate_source_line_numbers_are_rejected(self):
with self.assertRaises(HTTPException):
intake._lines([{'line_number':1},{'line_number':1}], use_line_numbers=True)
def test_invoice_requires_preview_and_barcode_requires_lookup(self):
request = intake.IntakeInput(request_id=uuid4(),mode='barcode',hardware={'asset_type':'pc'})
with self.assertRaises(HTTPException) as error:
asyncio.run(intake.create_from_intake(request))
self.assertEqual(error.exception.status_code, 422)
def test_repeated_serials_rejected_before_writes(self):
request = intake.IntakeInput(request_id=uuid4(),hardware={'asset_type':'pc'},count=2,serial_numbers=['SN-123','sn123'])
with self.assertRaises(HTTPException):
asyncio.run(intake.create_from_intake(request))
def test_upload_reuses_supplier_file_upload_and_returns_review(self):
from app.billing.backend import supplier_invoices
preview = {'preview_id':'test','lines':[{'description':'PC','quantity':'1'}]}
with patch.object(supplier_invoices, 'upload_supplier_invoice', AsyncMock(return_value={'file_id':42})), \
patch.object(intake, '_file_preview', AsyncMock(return_value=preview)) as parse:
result = asyncio.run(intake.upload_preview(UploadFile(filename='invoice.pdf',file=BytesIO(b'test fixture'))))
self.assertEqual(result, preview)
parse.assert_awaited_once_with(42)
@unittest.skipUnless(os.environ.get('BMC_HARDWARE_INTAKE_DB_TESTS') == '1', 'Opt-in rollback-only DB tests')
class IntakeDatabaseTests(unittest.TestCase):
def test_invoice_units_retry_partial_creation_and_atomic_serial_failure(self):
import psycopg2
from psycopg2.extras import RealDictCursor
from app.core.config import settings
from app.modules.hardware.backend import router as hardware
conn = psycopg2.connect(settings.DATABASE_URL)
def query(sql, params=None):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, params)
return list(cur.fetchall()) if cur.description else cur.rowcount
@contextmanager
def transaction():
query('SAVEPOINT intake_test_request')
try:
yield conn
except BaseException:
query('ROLLBACK TO SAVEPOINT intake_test_request')
raise
finally:
query('RELEASE SAVEPOINT intake_test_request')
token=uuid4().hex
try:
with patch.object(intake,'execute_query',query), patch.object(intake,'atomic_transaction',transaction), patch.object(hardware,'execute_query',query):
invoice = query("INSERT INTO supplier_invoices(invoice_number,vendor_name,invoice_date,status) VALUES(%s,'Hardware intake test',CURRENT_DATE,'pending') RETURNING id", (token,))[0]['id']
line_id = query("INSERT INTO supplier_invoice_lines(supplier_invoice_id,line_number,description,quantity,unit_price,line_total) VALUES(%s,1,'Test PC',3,100,300) RETURNING id", (invoice,))[0]['id']
preview = intake._invoice_preview(invoice)
request = intake.IntakeInput(request_id=uuid4(),mode='invoice',preview_id=preview['preview_id'],line_key=str(line_id),
count=2,serial_numbers=[token+'-a',token+'-b'],approved_unit_cost=Decimal('100'),
hardware={'asset_type':'pc','model':'Test PC '+token,'current_owner_type':'bmc','bmc_asset_category':'internal'})
result = asyncio.run(intake.create_from_intake(request))
ids=result['hardware_ids']
self.assertEqual(len(ids),2)
saved = query('SELECT id,serial_number,hardware_specs FROM hardware_assets WHERE id=ANY(%s)', (ids,))
self.assertEqual(len({row['serial_number'] for row in saved}),2)
self.assertEqual(saved[0]['hardware_specs']['acquisition']['approved_unit_cost'],'100')
self.assertEqual(query('SELECT status FROM supplier_invoices WHERE id=%s',(invoice,))[0]['status'],'pending')
self.assertEqual(len(query("SELECT id FROM supplier_invoice_relations WHERE supplier_invoice_id=%s AND relation_type='asset'", (invoice,))),2)
retry=asyncio.run(intake.create_from_intake(request))
self.assertTrue(retry['already_created'])
self.assertEqual(retry['hardware_ids'], sorted(ids))
remaining = intake._invoice_preview(invoice)
self.assertEqual(len(remaining['created_units'][str(line_id)]),2)
request2=request.model_copy(update={'request_id':uuid4(),'count':1,'serial_numbers':[token+'-c']})
self.assertEqual(len(asyncio.run(intake.create_from_intake(request2))['hardware_ids']),1)
with self.assertRaises(HTTPException):
asyncio.run(intake.create_from_intake(request.model_copy(update={'request_id':uuid4()})))
# A manual batch fails as one transaction if a later serial already exists.
before=query('SELECT COUNT(*) AS n FROM hardware_assets')[0]['n']
bad=intake.IntakeInput(request_id=uuid4(),count=2,serial_numbers=[token+'-new',token+'-a'],hardware={'asset_type':'pc','model':'Rollback test'})
with self.assertRaises(HTTPException):
asyncio.run(intake.create_from_intake(bad))
self.assertEqual(query('SELECT COUNT(*) AS n FROM hardware_assets')[0]['n'],before)
# Changed source quantity cannot be approved from an old preview.
query('UPDATE supplier_invoice_lines SET quantity=4 WHERE id=%s',(line_id,))
with self.assertRaises(HTTPException) as stale:
asyncio.run(intake.create_from_intake(request.model_copy(update={'request_id':uuid4(),'count':1,'serial_numbers':[]})))
self.assertEqual(stale.exception.status_code,409)
# Manual and barcode creation also preserve their source and hardware class.
for mode in ('manual','barcode'):
simple=intake.IntakeInput(request_id=uuid4(),mode=mode,code='5700000000000' if mode=='barcode' else None,
hardware={'asset_type':'pc','model':token+mode,'current_owner_type':'bmc','bmc_asset_category':'rental'})
asset_id=asyncio.run(intake.create_from_intake(simple))['hardware_ids'][0]
row=query('SELECT bmc_asset_category,hardware_specs FROM hardware_assets WHERE id=%s',(asset_id,))[0]
self.assertEqual(row['bmc_asset_category'],'rental')
self.assertEqual(row['hardware_specs']['acquisition']['mode'],mode)
finally:
conn.rollback()
conn.close()
def test_real_pdf_upload_review_and_reupload_do_not_duplicate_units(self):
import tempfile
import psycopg2
from psycopg2.extras import RealDictCursor
from reportlab.pdfgen import canvas
from app.core.config import settings
from app.modules.hardware.backend import router as hardware
from app.billing.backend import supplier_invoices
from app.services import invoice2data_service
from app.services.ollama_service import ollama_service
conn = psycopg2.connect(settings.DATABASE_URL)
def query(sql, params=None):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, params)
return list(cur.fetchall()) if cur.description else cur.rowcount
def one(sql, params=None):
rows=query(sql,params)
return rows[0] if rows else None
@contextmanager
def transaction():
query('SAVEPOINT pdf_intake_request')
try:
yield conn
except BaseException:
query('ROLLBACK TO SAVEPOINT pdf_intake_request')
raise
finally:
query('RELEASE SAVEPOINT pdf_intake_request')
token=uuid4().hex
pdf=BytesIO()
document=canvas.Canvas(pdf)
document.drawString(50,750,'Hardware fixture '+token)
document.drawString(50,720,'PC 2 x 100 DKK')
document.save()
def extract(text):
self.assertIn('Hardware fixture',text)
return {'invoice_number':token,'vendor_name':'Hardware fixture','currency':'DKK',
'lines':[{'line_number':1,'description':'PC','quantity':2,'unit_price':100}]}
parser=Mock(extract=Mock(side_effect=extract))
try:
with tempfile.TemporaryDirectory() as folder, patch.object(settings,'UPLOAD_DIR',folder), \
patch.object(intake,'execute_query',query), patch.object(intake,'atomic_transaction',transaction), \
patch.object(hardware,'execute_query',query), patch.object(supplier_invoices,'execute_query_single',one), \
patch.object(invoice2data_service,'get_invoice2data_service',return_value=parser), \
patch.object(ollama_service,'extract_from_text',AsyncMock(side_effect=AssertionError('Template fixture must not invoke AI'))):
preview=asyncio.run(intake.upload_preview(UploadFile(filename=token+'.pdf',file=BytesIO(pdf.getvalue()))))
self.assertEqual(preview['source_kind'],'file')
self.assertEqual(preview['lines'][0]['quantity'],'2')
request=intake.IntakeInput(request_id=uuid4(),mode='invoice',preview_id=preview['preview_id'],line_key='1',count=2,
serial_numbers=[token+'X',token+'Y'],hardware={'asset_type':'pc','model':'PDF fixture','current_owner_type':'bmc','bmc_asset_category':'rental'})
self.assertEqual(len(asyncio.run(intake.create_from_intake(request))['hardware_ids']),2)
repeated=asyncio.run(intake.upload_preview(UploadFile(filename=token+'.pdf',file=BytesIO(pdf.getvalue()))))
self.assertEqual(repeated['source_id'],preview['source_id'])
self.assertEqual(len(repeated['created_units']['1']),2)
invoice=one("INSERT INTO supplier_invoices(invoice_number,vendor_name,invoice_date,file_path,status) SELECT %s,'Hardware fixture',CURRENT_DATE,file_path,'pending' FROM incoming_files WHERE file_id=%s RETURNING id", (token,preview['source_id']))['id']
query("INSERT INTO supplier_invoice_lines(supplier_invoice_id,line_number,description,quantity,unit_price) VALUES(%s,1,'PC',2,100)", (invoice,))
registered=intake._invoice_preview(invoice)
self.assertEqual(len(registered['created_units']['1']),2)
with self.assertRaises(HTTPException):
asyncio.run(intake.create_from_intake(request.model_copy(update={'request_id':uuid4(),'preview_id':registered['preview_id']})))
finally:
conn.rollback()
conn.close()
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,70 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const nodes = {};
const get = id => nodes[id] ||= {value:'', hidden:false, className:'', innerHTML:'',textContent:'',focus(){},closest(){return this;}};
const requests=[];
let replies=[];
const ctx = {crypto:{randomUUID:()=> '11111111-1111-4111-8111-111111111111'},
document:{getElementById:get},URLSearchParams,
fetch:async url=>{requests.push(url); return {ok:true,json:async()=>replies.shift()};}};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync('static/js/hardware-intake.js','utf8'),ctx);
(async()=>{
vm.runInContext("hardwareIntake.mode='invoice'",ctx);
ctx.showHardwareInvoicePreview({preview_id:'p',header:{invoice_number:'F123',currency:'DKK'},created_units:{'7':[10]},
lines:[{key:'7',quantity:'3',unit_price:'0',description:'PC <safe>'}]});
ctx.chooseHardwareInvoiceLine(0);
assert.equal(get('intake-count').value,2);
assert.equal(get('intake-unit-cost').value,'0');
assert.equal(get('model').value,'PC <safe>');
assert.equal(get('intake-serials-group').hidden,false);
get('intake-serials').value='SNA\nSNB';
get('intake-currency').value='DKK';
const payload=ctx.hardwareIntakePayload({asset_type:'pc'});
assert.equal(payload.preview_id,'p');
assert.equal(payload.count,2);
assert.deepEqual(Array.from(payload.serial_numbers),['SNA','SNB']);
assert(get('intake-invoice-lines').innerHTML.includes('PC &lt;safe&gt;'));
ctx.setHardwareIntakeMode('manual');
assert.equal(ctx.hardwareIntakePayload({asset_type:'pc'}).preview_id,null);
vm.runInContext("hardwareIntake.mode='barcode'",ctx);
get('intake-code').value='0012345678901';
replies=[[],{products:[{name:'Test PC',sku:'SKU',ean:'0012345678901',manufacturer:'Maker',price:100,currency:'DKK'}]}];
await ctx.lookupHardwareCode();
assert(requests[0].includes('0012345678901'));
assert(requests[1].includes('/apigateway/search'));
ctx.chooseHardwareProduct(0);
assert.equal(get('brand').value,'Maker');
assert.equal(get('model').value,'Test PC');
assert.equal(ctx.hardwareIntakePayload({asset_type:'pc'}).code,'0012345678901');
assert.equal(get('serial_number').value,'');
// Selected machine data and serial are retained until another source is selected.
vm.runInContext("hardwareIntake.inventoryItems=[{name:'Mac <12>',brand:'Apple',model:'Mac mini',serial_number:'SN12',hardware_specs:{gateway_inventory:{id:12}}}]",ctx);
ctx.chooseHardwareInventory(0);
assert.equal(get('serial_number').value,'SN12');
get('intake-currency').value='DKK';
vm.runInContext("hardwareIntake.mode='manual'",ctx);
assert.equal(ctx.hardwareIntakePayload({asset_type:'pc'}).hardware.hardware_specs.gateway_inventory.id,12);
ctx.setHardwareIntakeMode('manual');
assert.equal(ctx.hardwareIntakePayload({asset_type:'pc'}).hardware.hardware_specs,undefined);
vm.runInContext("hardwareIntake.pairHardwareId=4;hardwareIntake.inventoryItems=[{id:12,name:'PC12'}]",ctx);
let reloaded = false;
ctx.window = {location:{reload(){reloaded=true;}}};
const writes = [];
ctx.fetch = async (url, options) => {writes.push({url,options});return {ok:true,json:async()=>({hardware_id:4})};};
await ctx.chooseHardwareInventory(0);
assert.equal(writes[0].url,'/api/v1/hardware-intake/inventory/4/pair');
assert.equal(JSON.parse(writes[0].options.body).inventory_id,12);
assert.equal(reloaded,true);
await ctx.unpairHardwareInventory();
assert.equal(writes[1].options.method,'DELETE');
ctx.fetch = async () => ({ok:false,json:async()=>({detail:'Gateway utilgængelig'})});
reloaded = false;
await ctx.chooseHardwareInventory(0);
assert.equal(reloaded,false);
assert.equal(get('intake-message').textContent,'Gateway utilgængelig');
console.log('Hardware intake UI OK: invoice quantities, serials, source reset, code lookup and EAN preservation');
})().catch(error=>{console.error(error);process.exitCode=1;});

View File

@ -0,0 +1,116 @@
"""Gateway inventory data must survive reviewed PC/Mac asset creation."""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.modules.hardware.backend import inventory
def test_windows_list_and_mac_detail_formats():
raw = {'hardware': {'manufacturer':'Apple','model':'Mac mini','cpu':{'name':'M4'},'ram_gb':24}, 'os':{'caption':'macOS'}}
for field, value in [('hardware_data',json.dumps(raw)), ('json_data',raw)]:
item = inventory.inventory_item({'id':12,'computer_name':'MAC-12','serial_number':'SN12',field:value})
assert (item['brand'],item['model'],item['cpu'],item['ram_gb'],item['os']) == ('Apple','Mac mini','M4',24,'macOS')
assert item['hardware_specs']['gateway_inventory']['data'] == raw
assert item['serial_number'] == 'SN12'
def response_session(status, payload):
response = MagicMock(status=status)
response.json = AsyncMock(return_value=payload)
context = MagicMock()
context.__aenter__ = AsyncMock(return_value=response)
session = MagicMock()
session.get.return_value = context
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=session)
return client, session
def test_search_uses_gateway_filter_and_pagination():
client, session = response_session(200, {'data':[{'id':1,'computer_name':'PC'}],'pagination':{'total_pages':3}})
with patch.object(inventory,'_apigw_headers',return_value={'Authorization':'Bearer fixture'}), patch.object(inventory.aiohttp,'ClientSession',return_value=client):
result = asyncio.run(inventory.search_inventory('PC', 'name', 2))
assert result['items'][0]['name'] == 'PC'
assert result['pagination']['total_pages'] == 3
assert session.get.call_args.kwargs['params'] == {'computer_name':'PC','per_page':20,'page':2}
def test_gateway_failure_is_sanitized_and_serial_not_found_is_empty():
for status, by in [(403,'name'), (404,'serial')]:
client, session = response_session(status, {'error':'private upstream data'})
with patch.object(inventory,'_apigw_headers',return_value={}), patch.object(inventory.aiohttp,'ClientSession',return_value=client), patch.object(inventory,'find_inventory_in_list',AsyncMock(return_value=None)):
if by == 'serial':
assert asyncio.run(inventory.search_inventory('SN/12', by, 1))['items'] == []
assert session.get.call_args.args[0].endswith('/serial/SN%2F12')
else:
with pytest.raises(HTTPException) as error:
asyncio.run(inventory.search_inventory('', by, 1))
assert error.value.status_code == 502
assert 'private' not in error.value.detail
def test_pair_fetches_trusted_gateway_record_and_merges_only_inventory():
item = inventory.inventory_item({'id':12,'computer_name':'PC12','json_data':{'hardware':{'ram_gb':16}}})
with patch.object(inventory,'fetch_inventory_record',AsyncMock(return_value=item)) as fetch, patch.object(inventory,'execute_query',side_effect=[[{'id':4}],[{'id':4}]]) as query:
result = asyncio.run(inventory.pair_inventory(4,inventory.InventoryPairInput(inventory_id=12)))
fetch.assert_awaited_once_with(12)
sql, params = query.call_args.args
assert "COALESCE(hardware_specs, '{}'::jsonb) ||" in sql
assert params[0].adapted == item['hardware_specs']
assert set(params[0].adapted) == {'gateway_inventory'}
assert 'serial_number =' not in sql
assert 'current_owner' not in sql
assert result['hardware_id'] == 4
def test_missing_asset_and_gateway_failure_never_write():
with patch.object(inventory,'execute_query',return_value=[]) as query, patch.object(inventory,'fetch_inventory_record',AsyncMock()) as fetch:
with pytest.raises(HTTPException) as error:
asyncio.run(inventory.pair_inventory(4,inventory.InventoryPairInput(inventory_id=12)))
assert error.value.status_code == 404
fetch.assert_not_awaited()
assert query.call_count == 1
with patch.object(inventory,'execute_query',return_value=[{'id':4}]) as query, patch.object(inventory,'fetch_inventory_record',AsyncMock(side_effect=HTTPException(502,'Unavailable'))):
with pytest.raises(HTTPException):
asyncio.run(inventory.pair_inventory(4,inventory.InventoryPairInput(inventory_id=12)))
assert query.call_count == 1
def test_unpair_removes_only_inventory_key():
with patch.object(inventory,'execute_query',return_value=[{'id':4}]) as query:
assert asyncio.run(inventory.unpair_inventory(4))['unpaired'] is True
assert "- 'gateway_inventory'" in query.call_args.args[0]
assert query.call_args.args[1] == (4,)
def test_detail_fetch_normalizes_gateway_record():
client, session = response_session(200, {'id':12,'computer_name':'Mac','json_data':{'os':{'caption':'macOS'}}})
with patch.object(inventory,'_apigw_headers',return_value={}), patch.object(inventory.aiohttp,'ClientSession',return_value=client):
result = asyncio.run(inventory.fetch_inventory_record(12))
assert session.get.call_args.args[0].endswith('/api/inventory/12')
assert result['os'] == 'macOS'
def test_pair_detail_404_falls_back_to_fresh_paginated_list():
client, session = response_session(404, {})
def context(status, payload):
return response_session(status, payload)[0].__aenter__.return_value.get.return_value
session.get.side_effect = [context(404, {}), context(200, {'data':[{'id':1}], 'pagination':{'total_pages':2}}), context(200, {'data':[{'id':'12','computer_name':'Mac','hardware_data':'{"hardware":{"ram_gb":24}}'}], 'pagination':{'total_pages':2}})]
with patch.object(inventory,'_apigw_headers',return_value={}), patch.object(inventory.aiohttp,'ClientSession',return_value=client):
item = asyncio.run(inventory.fetch_inventory_record(12))
assert item['name'] == 'Mac'
assert item['ram_gb'] == 24
assert session.get.call_args.kwargs['params']['page'] == 2
def test_missing_inventory_and_list_failure_do_not_pair_wrong_machine():
for status, expected in [(200,404),(503,502)]:
client, session = response_session(404,{})
list_client, _ = response_session(status,{'data':[{'id':99}], 'pagination':{'total_pages':1}})
session.get.side_effect = [session.get.return_value,list_client.__aenter__.return_value.get.return_value]
with patch.object(inventory,'_apigw_headers',return_value={}), patch.object(inventory.aiohttp,'ClientSession',return_value=client):
with pytest.raises(HTTPException) as error:
asyncio.run(inventory.fetch_inventory_record(12))
assert error.value.status_code == expected

View File

@ -0,0 +1,33 @@
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
process.env.TZ='Europe/Copenhagen';
const source=fs.readFileSync('static/js/planner.js','utf8'),host={innerHTML:''};
const state={meta:{users:[{id:1,name:'One'},{id:2,name:'Two'}]},data:{allocations:[],external_busy:[],absences:[
{id:1,user_id:1,kind_label:'Ferie',starts_at:'2026-10-24T22:00:00Z',ends_at:'2026-10-26T23:00:00Z'},
{id:2,user_id:2,kind_label:'Fravær',starts_at:'2026-10-26T09:00:00+01:00',ends_at:'2026-10-26T12:00:00+01:00'}
]}};
const context={Date,Math,Map,Number,String,state,$:()=>host,selectedUsers:()=>[1,2],
dateKey:d=>d.toLocaleDateString('en-CA'),esc:s=>String(s),fmtMin:m=>String(m||0),pad:n=>String(n).padStart(2,'0'),
cap:()=>({allocated_minutes:0,available_minutes:450,state:'green',label:'Ledig'}),integrationWarning:()=>'',
document:{querySelectorAll:()=>[]},bindCalendar:()=>{}};
vm.createContext(context);
vm.runInContext(source.slice(source.indexOf(' function blockStyle('),source.indexOf(' function bindCalendar(')),context);
const day=new Date('2026-10-25T12:00:00+01:00');
assert.equal(context.absencesOnDay(day,1).length,1);
assert.equal(context.absencesOnDay(new Date('2026-10-27T12:00:00+01:00'),1).length,0);
assert.match(context.absenceHtml(state.data.absences[0],day),/height:660px/);
context.renderTimeline(day,new Date('2026-10-26T12:00:00+01:00'));
assert.match(host.innerHTML,/planner-absence/);assert.match(host.innerHTML,/Ferie/);
assert.ok(host.innerHTML.indexOf('planner-absence-overview')<host.innerHTML.indexOf('week-grid'));
assert.match(host.innerHTML,/data-absence-user="1"/);assert.match(host.innerHTML,/>One<\/strong> · Ferie/);
assert.match(context.absenceHtml(state.data.absences[1],new Date('2026-10-26T12:00:00+01:00')),/top:120px;height:180px/);
context.renderMonth(new Date('2026-10-01T00:00:00+02:00'));
assert.equal((host.innerHTML.match(/planner-absence-month/g)||[]).length,3);
assert.match(host.innerHTML,/Ferie · One/);assert.match(host.innerHTML,/Fravær · Two/);
console.log('Team calendar absence: day/week/month, multiple days, partial days, exclusive end and DST OK');
// Jumping to an employee scrolls the calendar pane, keeping the outer page in place.
const button={dataset:{absenceUser:'1',absenceDate:'2026-10-25'}};
let scroll=null;
host.scrollTop=200;host.getBoundingClientRect=()=>({top:100});host.scrollTo=value=>{scroll=value;};
context.document={querySelectorAll:selector=>selector==='[data-absence-user]'?[button]:[],querySelector:()=>({getBoundingClientRect:()=>({top:900})})};
vm.runInContext(source.slice(source.indexOf(' function bindCalendar('),source.indexOf(' async function drop(')),context);
context.bindCalendar();button.onclick();assert.equal(scroll.top,952);assert.equal(scroll.behavior,'smooth');

View File

@ -0,0 +1,23 @@
/* Exercise the production adapter's ISO values, mode changes and required-field guard. */
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
function node(){return{children:[],classList:{add(){}},setAttribute(k,v){this[k]=v;},append(...items){this.children.push(...items);},prepend(item){this.children.unshift(item);},querySelector(){return this.children[0];}};}
const input={type:'date',dataset:{},value:'2026-10-25',required:true,disabled:false,labels:[{textContent:'Fra og med'}],getAttribute(){return null;},closest(selector){return selector==='dialog'?{}:null;}};
let options,opened=0,submit;
const error={textContent:''},form={dataset:{},querySelectorAll(){return[input];},querySelector(){return error;},addEventListener(name,handler){if(name==='submit')submit=handler;}};input.form=form;
const scope={querySelectorAll(selector){return selector==='input[data-planning-date-type]'&&!input.dataset.planningDateType?[]:[input];}};
function flatpickr(field,config){options=config;const picker={altInput:node(),calendarContainer:node(),setDate(value){field.value=value;},open(){opened++;},close(){},clear(){field.value='';},destroy(){delete field._flatpickr;}};field.type='hidden';field._flatpickr=picker;config.onReady([],field.value,picker);return picker;}
flatpickr.l10ns={da:{firstDayOfWeek:1}};
const context={window:{flatpickr},document:{createElement:node},Date};vm.createContext(context);vm.runInContext(fs.readFileSync('static/js/planning-dates.js','utf8'),context);
const dates=context.window.BMCPlanningDates;dates.mount(scope);
assert.equal(options.dateFormat,'Y-m-d');assert.equal(options.altFormat,'d.m.Y');assert.equal(options.enableTime,false);
assert.equal(input.value,'2026-10-25');assert.equal(options.locale.firstDayOfWeek,1);assert.equal(options.static,true);
assert.equal(input._flatpickr.altInput['aria-label'],'Fra og med');
dates.reset(scope);assert.equal(input.type,'date');assert.equal(input.value,'2026-10-25');
input.type='datetime-local';input.value='2026-10-25T08:30';dates.mount(scope);
assert.equal(options.enableTime,true);assert.equal(options.time_24hr,true);assert.equal(options.dateFormat,'Y-m-d\\TH:i');
assert.equal(input.value,'2026-10-25T08:30');
input.value='';let prevented=false,stopped=false;submit({preventDefault(){prevented=true;},stopImmediatePropagation(){stopped=true;}});
assert.ok(prevented&&stopped);assert.equal(opened,1);assert.match(error.textContent,/obligatoriske/);
input.value='2026-10-25T09:00';dates.mount(scope);assert.equal(input._flatpickr.altInput.disabled,false);
dates.reset(scope);assert.equal(input.type,'datetime-local');
console.log('Planning dates: Danish labels, ISO preservation, date/time switching, dialog placement and empty required fields OK');

View File

@ -0,0 +1,111 @@
/* Execute the production drag handlers with isolated API/DOM adapters. */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('static/js/planning-work.js', 'utf8');
const start = source.indexOf(' let taskDrag=null');
const end = source.indexOf(' async function tab(requested)', start);
assert.ok(start > 0 && end > start);
const elements = new Map(), calls = [], errors = [];
function element(id) {
if (!elements.has(id)) elements.set(id, {
value: '', dataset: {}, handlers: {}, hidden: false, textContent: '',
append() {}, setAttribute() {}, querySelectorAll() { return []; },
addEventListener(type, handler) { this.handlers[type] = handler; },
classList: { add() {}, remove() {}, toggle() {} },
});
return elements.get(id);
}
const root = element('root');
const queue = { version: 7, items: [{ sag_id: 14, titel: 'Allocated', case_updated_at: '2026-10-17T10:00:00Z' }] };
const context = {window:{},
console, Intl, Date, Number, JSON, Promise, root, queue, target: 1, uid: 1,
unassignedRows: [{ id: 12, titel: 'Inbox', case_updated_at: '2026-10-17T09:00:00Z' }],
unassignedOffset: 0,
metadata: { permissions: ['cases.edit','planner.allocate'], users: [{id:1,name:'One'},{id:2,name:'Two'}], editable_user_ids: [1,2] },
byId: element, esc: s=>String(s??''), loadQueue: async()=>{}, loadUnassigned: async()=>{},
error: err=>errors.push(err.message),canModifyEvent:()=>true,
calendar: { refetchEvents() {}, view: {type:'dayGridMonth'}, getDate:()=>new Date('2026-10-19T12:00:00Z') },
api: async (path,options={})=>{ calls.push({path,body:options.body?JSON.parse(options.body):null});return path.startsWith('/queue?')?{version:9}:{}; },
};
vm.createContext(context);
vm.runInContext(source.slice(start,end),context);
vm.runInContext(source.slice(source.indexOf(' function taskControls('),source.indexOf(' function renderQueue()')),context);
function drag(id,origin,kind='reorder') {
const task={dataset:{taskId:String(id),taskSource:origin},classList:{add(){}}};
root.handlers.dragstart({target:{closest:selector=>selector==='[data-task-kind]'?{dataset:{taskKind:kind}}:task},dataTransfer:{setData(){}},preventDefault(){throw Error('Unexpected rejected drag');}});
}
function employeeDrop(id) {
const button={dataset:{dropUser:String(id)},hasAttribute:()=>false,classList:{remove(){}}};
return element('workEmployeeDrops').handlers.drop({preventDefault(){},stopPropagation(){},target:{closest:()=>button}});
}
function dateDrop(day) {
return element('workCalendar').handlers.drop({preventDefault(){},stopPropagation(){},target:{closest:()=>({dataset:{date:day}})}});
}
(async()=>{
const reserved=context.reservationEvent({id:3,event_id:2,kind:'work',title:'Task',starts_at:'2026-10-19T08:00:00Z',ends_at:'2026-10-19T09:00:00Z'},'timeGridWeek');
assert.equal(reserved.display,'background');assert.equal(reserved.editable,false);assert.equal(reserved.title,'Allokeret: Task');
const meeting=context.reservationEvent({id:4,kind:'meeting',title:'Meeting'},'timeGridWeek');
assert.equal(meeting.display,'auto');assert.equal(meeting.extendedProps.reservation,undefined);
const list=context.reservationEvent({id:3,kind:'work',title:'Task'},'listWeek');
assert.equal(list.display,'auto');assert.equal(list.extendedProps.reservation,true);
const card=context.taskControls({sag_id:14,start_date:'2026-10-19',start_time:'09:00:00',deadline:'2026-10-20T15:00:00Z',planned_slots:[{starts_at:'2026-11-16T09:00:00Z',ends_at:'2026-11-16T10:00:00Z'},{starts_at:'2026-11-17T11:00:00Z',ends_at:'2026-11-17T12:00:00Z'}]});
for(const kind of ['start','deadline','slot'])assert.ok(card.includes('data-task-kind="'+kind+'"'));
assert.ok(card.includes('19.10.2026 kl. 09:00'));assert.ok(card.includes('2 tidsrum'));assert.equal((card.match(/<li>/g)||[]).length,2);
drag(12,'inbox');await employeeDrop(2);
assert.equal(calls.at(-1).path,'/cases/12/assignees');assert.equal(calls.at(-1).body.user_id,2);
drag(14,'queue');await employeeDrop(2);
assert.equal(calls.at(-1).path,'/cases/14/transfer');
assert.deepEqual(calls.at(-1).body,{source_user_id:1,target_user_id:2,source_version:7,target_version:9});
drag(12,'inbox','start');await dateDrop('2026-10-19');
assert.deepEqual(calls.at(-1).body,{updated_at:'2026-10-17T09:00:00Z',start_date:'2026-10-19'});
drag(14,'queue','deadline');await dateDrop('2026-11-02');
assert.deepEqual(calls.at(-1).body,{updated_at:'2026-10-17T10:00:00Z',deadline_date:'2026-11-02'});
const before=calls.length;
root.handlers.dragend();await dateDrop('2026-11-03');assert.equal(calls.length,before);
await assert.rejects(context.setTaskDate({id:14},'2026-11-03','start'),/Opgavelisten skal genindlæses/);
assert.equal(calls.length,before);
const slotDialogs=[];context.planTaskSlot=(task,day,clock)=>slotDialogs.push({id:task.id,day,clock});
drag(14,'queue','slot');await dateDrop('2026-11-05');await dateDrop('2026-11-06');
assert.deepEqual(slotDialogs.map(s=>s.day),['2026-11-05','2026-11-06']);assert.equal(calls.length,before);
// The actual time-grid drop target can be a lane spanning the whole week,
// with no dated ancestor. Screen geometry must resolve the right column.
const box=(date,left,right)=>({dataset:{date},getBoundingClientRect:()=>({left,right,top:100,bottom:700,width:right-left,height:600})});
const columns=[box('2026-10-19',100,200),box('2026-10-20',200,300)];
const lane={dataset:{time:'09:30:00'},getBoundingClientRect:()=>({top:220,bottom:240})};
const host=element('workCalendar');host.querySelectorAll=selector=>selector.includes('slot-lane')?[lane]:columns;
context.calendar.view.type='timeGridWeek';
const overLane={clientX:250,clientY:225,target:{closest:()=>null},preventDefault(){},stopPropagation(){}};
const hit=context.calendarTaskTarget(overLane);
assert.equal(hit.day,'2026-10-20');assert.equal(hit.clock,'09:30');assert.equal(hit.cell,columns[1]);
drag(14,'queue','slot');await host.handlers.drop(overLane);
assert.equal(slotDialogs.at(-1).day,'2026-10-20');assert.equal(slotDialogs.at(-1).clock,'09:30');
assert.equal(context.calendarTaskTarget({...overLane,clientX:350}).day,null);
context.calendar.view.type='dayGridMonth';host.querySelectorAll=()=>columns;
assert.equal(context.calendarTaskTarget(overLane).day,'2026-10-20');
host.querySelectorAll=()=>[];
context.api=async()=>{throw Error('Sagen er ændret. Genindlæs.');};
drag(14,'queue','deadline');await dateDrop('2026-11-04');assert.equal(errors.at(-1),'Sagen er ændret. Genindlæs.');
assert.ok(calls.every(call=>!call.path.includes('timer')&&!call.path.includes('/events')));
// The choice dialog sends the explicit action and the exact booking versions.
const snapshot=[{id:71,version:3,event_version:5}];
for(const action of ['transfer','remove','cancel']) {
let saved=null;
context.api=async()=>{const err=new Error('Choose');err.detail={code:'future_bookings_choice',booking_count:1,bookings:snapshot};throw err;};
context.saveWithConflict=async(path,options)=>{saved=JSON.parse(options.body);};
context.document={createElement:()=>{
const buttons=['transfer','remove','cancel'].map(action=>({dataset:{action}}));
return {querySelectorAll:()=>buttons,close(){},remove(){},showModal(){buttons.find(b=>b.dataset.action===action).onclick();}};
}};
const result=await context.transferWithBookings(14,{source_user_id:1,target_user_id:2,source_version:7,target_version:9});
assert.equal(result,action!=='cancel');
if(action==='cancel')assert.equal(saved,null);
else {assert.equal(saved.future_bookings_action,action);assert.deepEqual(saved.future_bookings,snapshot);assert.equal(saved.source_version,7);}
}
vm.runInContext(source.slice(source.indexOf(' function absenceEvent('),source.indexOf(' let visibleAbsences=')),context);
context.activityTypes=[{code:'vacation',label:'Ferie'}];
const absence=context.absenceEvent({id:9,kind:'vacation',all_day:true,display_start:'2026-10-15',display_end:'2026-10-17'});
assert.equal(absence.title,'Ferie');assert.equal(absence.allDay,true);assert.equal(absence.start,'2026-10-15');assert.equal(absence.end,'2026-10-17');
assert.equal(context.absenceEvent({id:10,kind:'unavailable'}).title,'Fravær');
console.log('Planning drag UI: assignment, transfer, start, deadline, external drag and stale changes OK');
})().catch(err=>{console.error(err);process.exitCode=1;});

View File

@ -0,0 +1,28 @@
"""Rental picker is customer-scoped and excludes completed/deleted cases."""
import asyncio
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from app.modules.rentals.backend import router
def test_picker_filters_customer_and_open_statuses_and_limits_results():
rows = [{'id':i,'titel':'Sag','status':'Ny'} for i in range(101)]
with patch.object(router,'execute_query_single',return_value={'id':1}), patch.object(router,'execute_query',return_value=rows) as query:
result = asyncio.run(router.quick_rent_cases(1,12,'PC'))
sql, params = query.call_args.args
assert params == (12,'%PC%','%PC%')
assert 'customer_id=%s' in sql and 'deleted_at IS NULL' in sql
for status in ['lukket','closed','afsluttet','done','løst','resolved']:
assert status in sql
assert len(result['cases']) == 100 and result['has_more'] is True
def test_picker_missing_hardware_and_empty_results():
with patch.object(router,'execute_query_single',return_value=None), patch.object(router,'execute_query') as query:
with pytest.raises(HTTPException) as error:
asyncio.run(router.quick_rent_cases(99,12,''))
assert error.value.status_code == 404
query.assert_not_called()
with patch.object(router,'execute_query_single',return_value={'id':1}), patch.object(router,'execute_query',return_value=[]):
assert asyncio.run(router.quick_rent_cases(1,12,'')) == {'cases':[],'has_more':False}

View File

@ -0,0 +1,39 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('app/modules/hardware/templates/detail.html','utf8');
const script = source.slice(source.indexOf(' let quickRentCaseGeneration'),source.indexOf(' async function submitQuickRent()')).replaceAll('{{ hardware.id }}','1');
const nodes = {};
const get = id => nodes[id] ||= {value:'',disabled:false,hidden:false,textContent:'',options:[],add(option){this.options.push(option);}};
let calls = [], refreshes = 0;
const context = {document:{getElementById:get},Option:function(text,value){this.text=text;this.value=value;},encodeURIComponent,refreshQuickRentPlan(){refreshes++;},intakeFetch:async(url,options)=>{calls.push({url,options});return {cases:[{id:9,titel:'PC',status:'Ny'}],has_more:false};}};
vm.createContext(context);
vm.runInContext(script,context);
(async()=>{
await context.loadQuickRentCases();
assert.equal(calls.length,0);
assert.equal(get('quickRentSagId').disabled,true);
get('quickRentCustomerId').value='12';
get('quickRentCaseSearch').value='PC';
await context.loadQuickRentCases();
assert(calls[0].url.includes('customer_id=12&q=PC'));
assert.equal(get('quickRentSagId').options[0].value,9);
assert.equal(get('quickRentSagId').disabled,false);
get('quickRentNewCaseTitle').value='Ny udlejning';
context.intakeFetch=async(url,options)=>{calls.push({url,options});return {id:10,titel:'Ny udlejning'};};
await context.createQuickRentCase();
const body=JSON.parse(calls.at(-1).options.body);
assert.equal(body.customer_id,12);
assert.deepEqual(body.hardware_ids,[1]);
assert.equal(get('quickRentSagId').value,'10');
assert.equal(get('quickRentNewCasePanel').hidden,true);
let finish;
context.intakeFetch=()=>new Promise(resolve=>{finish=resolve;});
const pending=context.loadQuickRentCases();
get('quickRentCustomerId').value='';
await context.loadQuickRentCases();
finish({cases:[{id:99}],has_more:false});
await pending;
assert.equal(get('quickRentSagId').disabled,true);
console.log('Rental case picker OK: customer lookup, asset-linked creation, customer-change race');
})().catch(error=>{console.error(error);process.exitCode=1;});

View File

@ -0,0 +1,31 @@
import asyncio
import json
from datetime import date
from unittest.mock import AsyncMock, patch
import pytest
from app.modules.rentals.backend import router
@pytest.mark.parametrize('months', [None, 0, 12])
def test_monthly_subscription_is_separate_from_startup_order(months):
args = dict(customer_id=2,sag_id=3,start_date=date(2026,10,15),start_price=15000,freight_price=79,preparation_price=1500,operations_monthly_price=1000)
if months is not None:
args['initial_operations_months'] = months
payload = router.QuickRentCreateInput(**args)
with patch.object(router,'execute_query_single',side_effect=[{'id':1,'current_owner_type':'bmc','model':'PC'},{'id':2},{'id':3,'customer_id':2},None]), patch.object(router,'_assert_asset_booking_available'), patch.object(router,'create_sag_subscription',AsyncMock(return_value={'id':12})) as subscription, patch.object(router,'create_sag_asset_binding',AsyncMock(return_value={'id':8})), patch.object(router,'with_invoice_fee',side_effect=lambda lines,customer:lines), patch.object(router,'execute_query',return_value=[{'id':252}]) as query:
result = asyncio.run(router.quick_rent_hardware(1,payload))
body = subscription.call_args.args[0]
assert body['billing_interval'] == 'monthly'
assert body['price'] == 1000
assert body['first_invoice_policy'] == 'start_date'
params = query.call_args.args[1]
lines = json.loads(params[2])
drift = [line for line in lines if line['line_key'] == 'rental-operations-initial']
if months == 12: # Explicit legacy API requests retain their existing behavior.
assert drift[0]['quantity'] == 12
assert params[5] == date(2027,10,15)
else:
assert drift == []
assert sum(line['quantity']*line['unit_price'] for line in lines) == 16579
assert params[4:6] == (None,None)
assert result['subscription_id'] == 12

View File

@ -0,0 +1,37 @@
"""Change-package creation uses dictionary rows throughout its transaction."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from app.subscriptions.backend import router
def test_change_package_creates_case_relation_and_commits():
cursor=MagicMock()
cursor.__enter__.return_value=cursor
cursor.fetchone.side_effect=[None,{'id':80},{'id':5,'change_sag_id':80},{'id':12,'sag_id':79,'version':1},{'id':6}]
conn=MagicMock()
conn.cursor.return_value=cursor
payload={'main_sag_id':79,'reason':'Ny pris','effective_date':'2026-11-01','proposals':[{'subscription_id':12,'changes':{'price':1200}}]}
with patch.object(router,'execute_query_single',return_value={'id':79,'titel':'Udlejning','customer_id':2}), patch.object(router,'get_db_connection',return_value=conn), patch.object(router,'release_db_connection') as release, patch.object(router,'_subscription_snapshot',return_value={'id':12}), patch.object(router,'_normalize_change_proposal',return_value={'price':1200}):
result=asyncio.run(router.save_subscription_change_request(payload,{'id':4}))
assert result['change_sag_id']==80 and result['status']=='draft'
relation=[call for call in cursor.execute.call_args_list if 'INSERT INTO sag_relationer' in call.args[0]]
assert relation[0].args[1]==(79,80)
conn.commit.assert_called_once()
conn.rollback.assert_not_called()
release.assert_called_once_with(conn)
def test_failed_package_rolls_back_new_case():
cursor=MagicMock()
cursor.__enter__.return_value=cursor
cursor.fetchone.side_effect=[None,{'id':80},{'id':5,'change_sag_id':80},None]
conn=MagicMock()
conn.cursor.return_value=cursor
with patch.object(router,'execute_query_single',return_value={'id':79,'titel':'Udlejning','customer_id':2}), patch.object(router,'get_db_connection',return_value=conn), patch.object(router,'release_db_connection'):
with pytest.raises(HTTPException) as error:
asyncio.run(router.save_subscription_change_request({'main_sag_id':79,'proposals':[{'subscription_id':12}]},{'id':4}))
assert error.value.status_code==400
conn.rollback.assert_called_once()
conn.commit.assert_not_called()

View File

@ -0,0 +1,35 @@
import asyncio
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from app.subscriptions.backend import router
@pytest.mark.parametrize('status,days',[('draft',0),('pending',0),('draft',7)])
def test_save_without_approval_applies_or_schedules(status,days):
cursor=MagicMock(); cursor.__enter__.return_value=cursor
cursor.fetchone.return_value={'id':1,'main_sag_id':79,'status':status,'effective_date':date.today()+timedelta(days=days),'created_by_user_id':4}
conn=MagicMock();conn.cursor.return_value=cursor
with patch.object(router,'get_db_connection',return_value=conn),patch.object(router,'release_db_connection'),patch.object(router,'apply_subscription_change_request',return_value={'id':1,'status':'applied'}) as apply:
result=asyncio.run(router.save_subscription_change_directly(1,{'id':4}))
conn.commit.assert_called_once()
assert 'approved_by_user_id=NULL' in cursor.execute.call_args_list[1].args[0]
assert 'change_saved_directly' in cursor.execute.call_args_list[2].args[0]
if days:
apply.assert_not_called();assert result['status']=='approved_scheduled'
else:
apply.assert_called_once_with(1,4);assert result['status']=='applied'
def test_repeat_save_does_not_apply_twice_and_rejected_is_blocked():
for status in ['applied','rejected']:
cursor=MagicMock();cursor.__enter__.return_value=cursor
cursor.fetchone.return_value={'status':status}
conn=MagicMock();conn.cursor.return_value=cursor
with patch.object(router,'get_db_connection',return_value=conn),patch.object(router,'release_db_connection'),patch.object(router,'apply_subscription_change_request') as apply:
if status=='applied': assert asyncio.run(router.save_subscription_change_directly(1,{'id':4}))['status']=='applied'
else:
with pytest.raises(HTTPException) as error: asyncio.run(router.save_subscription_change_directly(1,{'id':4}))
assert error.value.status_code==409
apply.assert_not_called();conn.rollback.assert_called_once()

View File

@ -0,0 +1,30 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const source=fs.readFileSync('app/modules/sag/templates/detail_v4.html','utf8');
const start=source.indexOf(' async function saveSelectedSubscriptionChange()');
const end=source.indexOf(' function openAgreementChangeComposer()',start);
const nodes={agreementChangeReason:{value:''},agreementEffectiveDate:{value:'2026-11-01'},agreementSaveChangeBtn:{disabled:false}};
const fields={'.subscription-change-select':{checked:true},'.sub-change-product':{value:'Drift'},'.sub-change-interval':{value:'monthly'},'.sub-change-schedule':{value:'fixed_day'},'.sub-change-day':{value:'15'},'.sub-change-status':{value:'active'}};
const card={dataset:{subscriptionId:'12'},querySelector:key=>fields[key]};
let calls=[],messages=[],loads=0,failSubmit=false;
const ctx={document:{getElementById:id=>nodes[id],querySelectorAll:()=>[card]},subscriptionCaseId:79,collectAgreementChangeLines:()=>[{description:'Drift',quantity:1,unit_price:1200}],showCaseFeedback:(...args)=>messages.push(args),loadSubscriptionForCase:async()=>{loads++;},fetch:async(url,options)=>{calls.push({url,options});return {ok:!(failSubmit&&url.endsWith('/save')),json:async()=>url.endsWith('/save')?{detail:'Prøv igen'}:{id:5}};}};
vm.createContext(ctx);vm.runInContext(source.slice(start,end),ctx);
(async()=>{
await ctx.saveSelectedSubscriptionChange();
assert.equal(calls.length,2);
assert(calls[1].url.endsWith('/5/save'));
const body=JSON.parse(calls[0].options.body);
assert.equal(body.proposals[0].subscription_id,12);
assert.equal(body.proposals[0].changes.line_items[0].unit_price,1200);
assert.equal(body.reason,'Ændring af abonnement');
assert.equal(loads,1);
assert.equal(messages.at(-1)[1],'success');
assert.equal(nodes.agreementSaveChangeBtn.disabled,false);
failSubmit=true;
await ctx.saveSelectedSubscriptionChange();
assert.equal(loads,1);
assert(messages.at(-1)[0].includes('kunne ikke gennemføres'));
assert.equal(nodes.agreementSaveChangeBtn.disabled,false);
console.log('Simple subscription change OK: one save applies without approval, failure preserves draft, no redirect');
})().catch(error=>{console.error(error);process.exitCode=1;});

View File

@ -0,0 +1,19 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source=fs.readFileSync('app/modules/sag/templates/detail_v4.html','utf8');
const start=source.indexOf(' function updateSubscriptionTabCount(payload)');
const end=source.indexOf(' async function loadSubscriptionForCase()',start);
let badge;
const context={window:{},_setCaseTabCountBadge:(id,value)=>{assert.equal(id,'subscriptionTabCountBadge');badge=value;}};
vm.createContext(context);
vm.runInContext(source.slice(start,end),context);
context.updateSubscriptionTabCount({current_subscriptions:[{id:12,status:'draft'}]});
assert.equal(badge,1);
context.updateSubscriptionTabCount({current_subscriptions:[{id:12}],upcoming_subscriptions:[{id:'12'},{id:13}],ended_subscriptions:[{id:14}]});
assert.equal(badge,3);
context.updateSubscriptionTabCount({});
assert.equal(badge,0);
assert(!source.includes("const subscriptionCount = _countRows('#subscriptionItemsBody')"));
assert(fs.readFileSync('app/modules/sag/templates/detail_v5.html','utf8').includes('extends "modules/sag/templates/detail_v4.html"'));
console.log('Subscription tab count OK: draft, unique agreements, empty case, V5 inheritance');

View File

@ -0,0 +1,717 @@
from datetime import date, datetime, time, timedelta
from pathlib import Path
from uuid import uuid4
import os
import pytest
from fastapi import HTTPException
from app.modules.planner.backend import work, calendar, capacity
def test_readiness_respects_waiting_pause_future_start_and_dependencies():
now=datetime(2026,10,17,10,tzinfo=work.TZ)
assert work.ready({'status':'åben','work_status':'ready'},now)
for extra in ({'status':'afventer'},{'work_status':'paused'},
{'start_date':date(2026,10,18)}, {'snoozed_until':now+timedelta(hours=1)},
{'deferred_until':datetime(2026,10,17,11)},
{'deferred_until_case_id':12,'deferred_until_status':'afsluttet','deferred_case_status':'åben'}):
assert not work.ready({'status':'åben','work_status':'ready',**extra},now)
assert work.ready({'deferred_until_case_id':12,'deferred_until_status':'afsluttet, lukket','deferred_case_status':'lukket'},now)
def test_remaining_estimate_does_not_duplicate_unallocated_case_estimate():
assert work.remaining({'case_estimated_minutes':720,'actual_minutes':60}) is None
assert work.remaining({'estimated_minutes':120,'actual_minutes':90})==30
assert work.remaining({'estimated_minutes':120,'actual_minutes':180})==0
assert work.remaining({'remaining_minutes':45,'estimated_minutes':120,'actual_minutes':180})==45
def test_capacity_unions_busy_intervals_and_ignores_nights():
start=datetime(2026,10,19,tzinfo=work.TZ)
schedules=[{'weekday':0,'start_time':time(8),'end_time':time(16),'break_minutes':0}]
occupied=[(start.replace(hour=9),start.replace(hour=12)),(start.replace(hour=10),start.replace(hour=13)),
(start.replace(hour=20),start.replace(hour=22))]
assert capacity.working_capacity(schedules,occupied,start,start+timedelta(days=1))=={
'normal_minutes':480,'busy_minutes':240,'free_minutes':240}
def test_absence_only_registers_scheduled_days_and_partial_hours():
schedules=[{'weekday':d,'start_time':time(8),'end_time':time(16),'break_minutes':30} for d in range(5)]
period={'starts_at':datetime(2026,10,16,12,tzinfo=work.TZ),'ends_at':datetime(2026,10,19,12,tzinfo=work.TZ)}
days=calendar.absence_work_days(period,schedules)
assert [r[0] for r in days]==[date(2026,10,16),date(2026,10,19)]
assert [r[3] for r in days]==[240,240]
assert calendar.absence_work_minutes({'starts_at':datetime(2026,10,19,8,tzinfo=work.TZ),
'ends_at':datetime(2026,10,19,16,tzinfo=work.TZ)},schedules)==450
def test_calendar_rejects_naive_or_reversed_dates():
with pytest.raises(ValueError):
calendar.EventInput(title='Test',user_ids=[1],starts_at=datetime(2026,10,17),ends_at=datetime(2026,10,18))
with pytest.raises(ValueError):
calendar.AbsenceInput(user_id=1,kind='vacation',starts_at=datetime(2026,10,18,tzinfo=work.TZ),ends_at=datetime(2026,10,17,tzinfo=work.TZ))
@pytest.fixture
def planning_db():
if os.environ.get('BMC_PLANNING_DB_TESTS')!='1':
pytest.skip('Opt in to a dedicated local planning test database')
import psycopg2
from psycopg2.extensions import parse_dsn, make_dsn
from psycopg2.pool import ThreadedConnectionPool
from app.core import database
from app.core.config import settings
target=os.environ.get('BMC_PLANNING_TEST_DATABASE','')
if not target.startswith('bmc_planning_test_'):
pytest.fail('A dedicated bmc_planning_test_* database is required')
values=parse_dsn(settings.DATABASE_URL)
values.update(host='127.0.0.1',port='5433',dbname=target)
original=database.connection_pool
pool=ThreadedConnectionPool(1,6,dsn=make_dsn(**values))
database.connection_pool=pool
try:
from app.core.database import execute_query, execute_query_single, atomic_transaction
with atomic_transaction():
# Only the dedicated disposable database may have test schema adjusted.
execute_query('DROP INDEX IF EXISTS planner_actual_absence',fetch=False)
execute_query(Path('migrations/1070_unified_planning.sql').read_text(),fetch=False)
execute_query(Path('migrations/1071_case_confidentiality.sql').read_text(),fetch=False)
execute_query(Path('migrations/1072_absence_reports.sql').read_text(),fetch=False)
suffix=uuid4().hex[:10]
users=[]
for i in range(3):
users.append(execute_query_single("INSERT INTO users(username,email,password_hash,is_active) VALUES(%s,%s,'disabled-test-account',true) RETURNING user_id",(f'plan_{suffix}_{i}',f'plan_{suffix}_{i}@example.invalid'))['user_id'])
actor={'id':users[0],'is_superadmin':True,'permissions':[]}
cases=[]
for i in range(3):
cases.append(execute_query_single("INSERT INTO sag_sager(titel,created_by_user_id,status,type) VALUES(%s,%s,'åben','ticket') RETURNING id",(f'Planning test {suffix} {i}',users[0]))['id'])
yield actor,users,cases
finally:
database.connection_pool=original
pool.closeall()
def test_db_independent_queues_stale_versions_and_transaction_rollback(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
for uid in users:
for cid in cases:
work.add_assignee(cid,work.AssignmentInput(user_id=uid),actor)
a=work.get_queue(users[0],100,0,actor)
b=work.get_queue(users[1],100,0,actor)
work.change_queue(users[0],cases[2],work.QueueChange(version=a['version'],before_sag_id=cases[0]),actor)
assert [r['sag_id'] for r in work.get_queue(users[0],100,0,actor)['items']]==[cases[2],cases[0],cases[1]]
assert [r['sag_id'] for r in work.get_queue(users[1],100,0,actor)['items']]==[r['sag_id'] for r in b['items']]
with pytest.raises(HTTPException) as error:
work.change_queue(users[0],cases[0],work.QueueChange(version=a['version'],pinned=True),actor)
assert error.value.status_code==409
assert execute_query_single('SELECT pinned FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(cases[0],users[0]))['pinned'] is False
def test_db_shared_event_preserves_participant_ids_and_registers_absence_once(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
start=datetime(2026,10,19,10,tzinfo=work.TZ)
payload=calendar.EventInput(title='Shared',sag_id=cases[0],user_ids=users,starts_at=start,ends_at=start+timedelta(hours=1))
event=calendar.create_event(payload,actor)['event']
before=execute_query('SELECT id FROM planner_allocations WHERE event_id=%s ORDER BY id',(event['id'],))
moved=payload.model_copy(update={'version':event['version'],'starts_at':start+timedelta(hours=2),'ends_at':start+timedelta(hours=3)})
event=calendar.update_event(event['id'],moved,actor)['event']
visible=calendar.schedule(start,start+timedelta(days=1),[users[0]],actor)
assert visible['bookings'][0]['participants']==sorted(users)
after=execute_query('SELECT id,starts_at FROM planner_allocations WHERE event_id=%s ORDER BY id',(event['id'],))
assert [r['id'] for r in before]==[r['id'] for r in after]
assert all(r['starts_at']==moved.starts_at for r in after)
assert len(work.get_queue(users[1],100,0,actor)['items'])==1
calendar.cancel_event(event['id'],event['version'],actor)
assert not execute_query('SELECT id FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL',(event['id'],))
for weekday in (0,1):
execute_query('INSERT INTO planner_work_schedules(user_id,weekday,break_minutes) VALUES(%s,%s,0)',(users[0],weekday),fetch=False)
absence=calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',starts_at=datetime(2026,10,12,8,tzinfo=work.TZ),ends_at=datetime(2026,10,13,16,tzinfo=work.TZ)),actor)['absence']
one=calendar.register_absence(absence['id'],absence['version'],actor)
two=calendar.register_absence(absence['id'],absence['version'],actor)
assert one['time_entry_ids']==two['time_entry_ids']
assert len(one['time_entry_ids'])==2
assert two['already_registered']
rows=execute_query('SELECT billable,fakturerbar_tid_min FROM tmodule_times WHERE planner_absence_id=%s',(absence['id'],))
assert all(not r['billable'] and r['fakturerbar_tid_min']==0 for r in rows)
def test_db_confidentiality_hides_queue_calendar_api_and_allows_group(planning_db):
import asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import case_visibility as policy
from app.modules.sag.backend import visibility
from app.core.database import execute_query,execute_query_single
actor,users,cases=planning_db
reader={'id':users[1],'permissions':['planner.view','planner.edit_own','cases.view']}
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[1]),actor)
start=datetime(2026,11,2,10,tzinfo=work.TZ)
calendar.create_event(calendar.EventInput(title='Confidential title',description='Private note',sag_id=cases[0],user_ids=[users[1]],starts_at=start,ends_at=start+timedelta(hours=1)),actor)
locked=visibility.update_visibility(cases[0],visibility.VisibilityInput(restricted=True,version=0,user_ids=[users[0]]),actor)
assert cases[0] in policy.hidden_case_ids(reader)
assert not work.get_queue(users[1],100,0,reader)['items']
busy=calendar.schedule(start,start+timedelta(days=1),[users[1]],reader)['bookings'][0]
assert busy['title']=='Optaget' and busy['private']
assert not {'sag_id','description','event_id'} & busy.keys()
with pytest.raises(HTTPException) as error:
visibility.get_visibility(cases[0],reader)
assert error.value.status_code==404
app=FastAPI()
@app.middleware('http')
async def privacy(request,call_next):
return await policy.protect_request(request,call_next,reader)
@app.get('/api/v1/search')
def search():
return {'items':[{'id':cases[0],'title':'Confidential title','url':f'/sag/{cases[0]}'}]}
@app.get('/sag/{sag_id}')
def details(sag_id:int):
return {'titel':'Confidential title'}
calls=[]
@app.post('/api/v1/test-write')
def write():
calls.append(True)
return {'ok':True}
with TestClient(app) as client:
assert client.get('/api/v1/search').json()=={'items':[]}
assert client.get(f'/sag/{cases[0]}').status_code==404
assert client.post('/api/v1/test-write',json={'sag_id':cases[0]}).status_code==404
assert not calls
group=execute_query_single('INSERT INTO groups(name) VALUES(%s) RETURNING id',(f'privacy_{uuid4().hex}',))['id']
execute_query('INSERT INTO user_groups(user_id,group_id) VALUES(%s,%s)',(users[1],group),fetch=False)
updated=visibility.update_visibility(cases[0],visibility.VisibilityInput(restricted=True,version=locked['version'],group_ids=[group]),actor)
assert cases[0] not in policy.hidden_case_ids(reader)
assert work.get_queue(users[1],100,0,reader)['items'][0]['sag_id']==cases[0]
# Unlocked/relocked cases keep a monotonic version (no stale version reuse).
unlocked=visibility.update_visibility(cases[0],visibility.VisibilityInput(restricted=False,version=updated['version']),actor)
assert unlocked['version']>updated['version']
def test_private_calendar_and_nested_notifications_are_scrubbed():
from app.core.case_visibility import filter_data
data={'sections':{'urgent':{'list':[{'id':174,'title':'Secret'}]}},
'notifications':[{'sag_id':174,'case_title':'Secret','message':'Private'}],
'bookings':[{'sag_id':174,'id':9,'case_title':'Secret','customer_name':'Secret customer',
'starts_at':'2026-10-17T10:00:00Z','ends_at':'2026-10-17T11:00:00Z'}]}
result=filter_data(data,{174})
assert result['sections']['urgent']['list']==[]
assert result['notifications']==[]
assert result['bookings'][0]['title']=='Optaget'
assert 'Secret' not in str(result)
def test_db_case_start_clock_and_stale_case_update(planning_db):
actor,users,cases=planning_db
row=work.case_planning(cases[0],actor)
result=work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=row['updated_at'],
start_date=date(2026,10,20),start_time=time(13),estimated_minutes=720),actor)
assert result['start_date']==date(2026,10,20) and result['start_time']==time(13)
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=row['updated_at'],estimated_minutes=30),actor)
assert error.value.status_code==409
def test_db_atomic_individual_distribution_rolls_back_on_stale_queue(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
for uid in users[:2]:
work.add_assignee(cases[0],work.AssignmentInput(user_id=uid,estimated_minutes=120),actor)
plan=work.case_planning(cases[0],actor)
versions={r['user_id']:r['queue_version'] for r in plan['assignees']}
work.change_queue(users[1],cases[0],work.QueueChange(version=versions[users[1]],pinned=True),actor)
payload=work.CasePlanningInput(updated_at=plan['updated_at'],estimated_minutes=600,
assignee_estimates=[work.AssigneeEstimateInput(user_id=uid,queue_version=versions[uid],estimated_minutes=200) for uid in users[:2]],
add_assignee=work.AssignmentInput(user_id=users[2],estimated_minutes=200))
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],payload,actor)
assert error.value.status_code==409
after=work.case_planning(cases[0],actor)
assert after['estimated_minutes']==plan['estimated_minutes']
assert len(after['assignees'])==2
assert all(r['estimated_minutes']==120 for r in after['assignees'])
for item in payload.assignee_estimates:
item.queue_version=work.get_queue(item.user_id,100,0,actor)['version']
work.update_case_planning(cases[0],payload,actor)
assert len(work.case_planning(cases[0],actor)['assignees'])==3
assert execute_query_single('SELECT COUNT(*) AS n FROM planner_allocation_history WHERE sag_id=%s AND action=\'case_plan_update\'',(cases[0],))['n']==1
def test_db_concurrent_queue_and_booking_updates_have_one_winner(planning_db):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
actor,users,cases=planning_db
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
version=work.get_queue(users[0],100,0,actor)['version']
barrier=Barrier(2)
def queue_change(value):
barrier.wait(timeout=5)
try:
work.change_queue(users[0],cases[0],work.QueueChange(version=version,estimated_minutes=value),actor)
return 200
except HTTPException as exc:
return exc.status_code
with ThreadPoolExecutor(2) as executor:
assert sorted(executor.map(queue_change,[60,120]))==[200,409]
start=datetime(2026,11,9,10,tzinfo=work.TZ)
payload=calendar.EventInput(title='Concurrent',user_ids=[users[0]],starts_at=start,ends_at=start+timedelta(hours=1))
event=calendar.create_event(payload,actor)['event']
barrier=Barrier(2)
def event_change(offset):
barrier.wait(timeout=5)
try:
calendar.update_event(event['id'],payload.model_copy(update={'version':event['version'],
'starts_at':start+timedelta(hours=offset),'ends_at':start+timedelta(hours=offset+1)}),actor)
return 200
except HTTPException as exc:
return exc.status_code
with ThreadPoolExecutor(2) as executor:
assert sorted(executor.map(event_change,[2,4]))==[200,409]
def test_db_deadline_scheduler_deduplicates_and_uses_existing_reminders(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
from app.modules.planner.backend.checks import run_planning_checks
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
execute_query("UPDATE sag_sager SET deadline=(NOW() AT TIME ZONE 'UTC')+INTERVAL '2 hours' WHERE id=%s",(cases[0],),fetch=False)
run_planning_checks()
run_planning_checks()
result=execute_query_single("SELECT COUNT(*) AS n,BOOL_AND(notify_frontend AND NOT notify_email AND NOT notify_mattermost) AS local_only FROM sag_reminders WHERE sag_id=%s AND event_type='deadline'",(cases[0],))
assert result['n']==1 and result['local_only']
def test_db_transfer_failure_restores_source_and_responsible(planning_db,monkeypatch):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
execute_query('UPDATE sag_sager SET ansvarlig_bruger_id=%s WHERE id=%s',(users[0],cases[0]),fetch=False)
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0],estimated_minutes=180),actor)
versions=[work.get_queue(uid,100,0,actor)['version'] for uid in users[:2]]
original=work.audit
def fail(*args,**kwargs):
raise RuntimeError('Synthetic audit failure')
monkeypatch.setattr(work,'audit',fail)
payload=work.TransferInput(source_user_id=users[0],target_user_id=users[1],source_version=versions[0],target_version=versions[1])
with pytest.raises(RuntimeError):
work.transfer(cases[0],payload,actor)
assert [r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_assignees WHERE sag_id=%s',(cases[0],))]==[users[0]]
assert [work.get_queue(uid,100,0,actor)['version'] for uid in users[:2]]==versions
monkeypatch.setattr(work,'audit',original)
work.transfer(cases[0],payload,actor)
assert execute_query_single('SELECT ansvarlig_bruger_id FROM sag_sager WHERE id=%s',(cases[0],))['ansvarlig_bruger_id']==users[0]
assert [r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_assignees WHERE sag_id=%s',(cases[0],))]==[users[1]]
def test_db_absence_reasons_are_masked_but_period_editing_is_allowed(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
from app.core.case_visibility import scrub_absence_time
reader={'id':users[1],'permissions':['planner.view','planner.view_team','planner.edit_team','planner.absence_team','cases.view']}
execute_query('INSERT INTO planner_team_scope(editor_user_id,target_user_id) VALUES(%s,%s)',(users[1],users[0]),fetch=False)
start=datetime(2026,11,16,8,tzinfo=work.TZ)
item=calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='sickness',note='Private medical note',starts_at=start,ends_at=start+timedelta(hours=8)),actor)['absence']
view=calendar.schedule(start,start+timedelta(days=1),[users[0]],reader)['absences'][0]
assert view['kind']=='unavailable' and not view['note']
result=calendar.update_absence(item['id'],calendar.AbsenceInput(user_id=users[0],version=item['version'],starts_at=start+timedelta(hours=1),ends_at=start+timedelta(hours=8)),reader)
assert result['absence']['kind']=='unavailable'
redacted=scrub_absence_time({'planner_absence_id':item['id'],'planner_activity_code':'sickness','medarbejder_id':users[0],'description':'Sygdom'},users[1],False)
assert redacted['description']=='Fravær' and redacted['planner_activity_code'] is None
def test_work_windows_respect_danish_clock_changes():
from datetime import timezone
schedules=[{'user_id':1,'weekday':6,'start_time':time(1),'end_time':time(4),'break_minutes':0}]
# On the winter transition 01:00-04:00 spans four real hours.
start=datetime(2026,10,25,1,tzinfo=work.TZ)
end=datetime(2026,10,25,4,tzinfo=work.TZ)
assert (end.astimezone(timezone.utc)-start.astimezone(timezone.utc)).total_seconds()==14400
assert not calendar.outside_work_hours(1,start,end,schedules)
assert capacity.working_capacity(schedules,[],start,end)['normal_minutes']==240
assert calendar.absence_work_minutes({'starts_at':start,'ends_at':end},schedules)==240
def test_db_parent_summary_deduplicates_shared_descendants(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
for parent,child in [(cases[0],cases[1]),(cases[0],cases[2]),(cases[1],cases[2])]:
execute_query("INSERT INTO sag_relationer(kilde_sag_id,målsag_id,relationstype) VALUES(%s,%s,'undersag')",(parent,child),fetch=False)
for cid in cases:
execute_query('UPDATE sag_sager SET estimated_minutes=120 WHERE id=%s',(cid,),fetch=False)
execute_query("INSERT INTO tmodule_times(sag_id,medarbejder_id,description,original_hours,faktisk_tid_min,status) VALUES(%s,%s,'Synthetic time',1,60,'pending')",(cid,users[0]),fetch=False)
total=capacity.case_summary(cases[0],True,actor,'all')
children=capacity.case_summary(cases[0],True,actor,'children')
own=capacity.case_summary(cases[0],False,actor,'own')
assert total['case_count']==3 and total['actual_minutes']==180 and total['remaining_minutes']==180
assert children['case_count']==2 and children['actual_minutes']==120
assert own['case_count']==1 and own['actual_minutes']==60
def test_db_next_task_warns_and_does_not_create_time(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
for cid,estimate in zip(cases,[180,5,None]):
work.add_assignee(cid,work.AssignmentInput(user_id=users[0],estimated_minutes=estimate),actor)
start=datetime.now(work.TZ)+timedelta(minutes=20)
count=execute_query_single('SELECT COUNT(*) AS n FROM tmodule_times')['n']
calendar.create_event(calendar.EventInput(title='Fixed meeting',user_ids=[users[0]],starts_at=start,ends_at=start+timedelta(hours=1)),actor)
result=work.next_task(actor)
assert result['task']['sag_id']==cases[0] and result['warning']
assert result['shorter_task']['sag_id']==cases[1]
assert execute_query_single('SELECT COUNT(*) AS n FROM tmodule_times')['n']==count
def test_db_queue_paginates_and_searches_two_thousand_cases(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
from time import monotonic
with work.atomic_transaction():
execute_query('''WITH new_cases AS (
INSERT INTO sag_sager(titel,created_by_user_id,status,type)
SELECT 'Large queue '||i,%s,'åben','ticket' FROM generate_series(1,2000) i RETURNING id)
INSERT INTO sag_case_assignees(sag_id,user_id) SELECT id,%s FROM new_cases''',(users[0],users[0]),fetch=False)
started=monotonic()
first=work.get_queue(users[0],100,0,actor)
second=work.get_queue(users[0],100,100,actor)
found=work.get_queue(users[0],100,0,actor,'Large queue 2000')
assert len(first['items'])==len(second['items'])==100
assert not {r['sag_id'] for r in first['items']} & {r['sag_id'] for r in second['items']}
assert len(found['items'])==1 and found['items'][0]['titel']=='Large queue 2000'
assert monotonic()-started<10 # A generous bound catches accidental per-row round trips.
def test_db_existing_calendar_feed_reads_shared_event_once(planning_db):
from types import SimpleNamespace
from app.modules.calendar.backend.router import _planning_feed_events,_build_ical_response
actor,users,cases=planning_db
from app.core.database import execute_query
execute_query('UPDATE users SET is_superadmin=TRUE WHERE user_id=%s',(users[0],),fetch=False)
start=datetime(2026,11,23,10,tzinfo=work.TZ)
calendar.create_event(calendar.EventInput(title='Shared feed event',sag_id=cases[0],user_ids=users,
starts_at=start,ends_at=start+timedelta(hours=1)),actor)
request=SimpleNamespace(state=SimpleNamespace(user_id=users[0]))
events=_planning_feed_events(request,start,start+timedelta(days=1),users[0],False,None,{'planner_booking'})
selected=[e for e in events if e.get('sag_id')==cases[0]]
assert len(selected)==1
assert _build_ical_response(selected,start).body.count(b'BEGIN:VEVENT')==1
def test_db_unassigned_search_paging_and_assignment(planning_db):
from app.core.database import execute_query
from app.modules.sag.backend import visibility
actor,users,cases=planning_db
marker=f'Unassigned-{cases[0]}'
for cid in cases:
execute_query('UPDATE sag_sager SET titel=%s,ansvarlig_bruger_id=%s WHERE id=%s',
(marker+' '+str(cid),users[0],cid),fetch=False)
first=capacity.unassigned(2,0,actor,marker)
assert len(first['items'])==2 and first['has_more']
assert all(row['responsible_name'] for row in first['items'])
second=capacity.unassigned(2,2,actor,marker)
assert len(second['items'])==1 and not second['has_more']
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[1],estimated_minutes=60),actor)
remaining=capacity.unassigned(25,0,actor,marker)
assert {row['id'] for row in remaining['items']}==set(cases[1:])
assert work.get_queue(users[1],100,0,actor)['items'][0]['estimated_minutes']==60
visibility.update_visibility(cases[1],visibility.VisibilityInput(restricted=True,user_ids=[users[0]],version=0),actor)
reader={'id':users[2],'permissions':['cases.view','planner.view','planner.allocate']}
hidden=capacity.unassigned(25,0,reader,marker)
assert [row['id'] for row in hidden['items']]==[cases[2]]
def test_db_drag_dates_preserve_clock_and_detect_stale_case(planning_db):
actor,users,cases=planning_db
original=work.case_planning(cases[0],actor)
initial=work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=original['updated_at'],
start_date=date(2026,10,23),start_time=time(9),deadline=datetime(2026,10,23,13,30,tzinfo=work.TZ)),actor)
moved=work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=initial['updated_at'],
deadline_date=date(2026,11,2)),actor)
assert moved['deadline']==datetime(2026,11,2,13,30,tzinfo=work.TZ)
assert moved['start_date']==date(2026,10,23) and moved['start_time']==time(9)
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=initial['updated_at'],start_date=date(2026,11,1)),actor)
assert error.value.status_code==409
reader={'id':users[1],'permissions':['cases.view','planner.view']}
with pytest.raises(HTTPException) as error:
work.update_case_planning(cases[0],work.CasePlanningInput(updated_at=moved['updated_at'],deadline_date=date(2026,11,3)),reader)
assert error.value.status_code==403
next_plan=work.case_planning(cases[1],actor)
new=work.update_case_planning(cases[1],work.CasePlanningInput(updated_at=next_plan['updated_at'],
start_date=date(2026,11,2),deadline_date=date(2026,11,3)),actor)
assert new['deadline'].hour==17
schedule=calendar.schedule(datetime(2026,11,1,tzinfo=work.TZ),datetime(2026,11,5,tzinfo=work.TZ),[users[0]],actor,True)
marker=next(m for m in schedule['milestones'] if m['sag_id']==cases[1] and m['kind']=='case_start')
assert marker['allDay'] and marker['case_updated_at']==new['updated_at']
def test_db_card_keeps_multiple_allocated_time_slots(planning_db):
actor,users,cases=planning_db
start=datetime(2026,11,16,10,tzinfo=work.TZ)
payload=calendar.EventInput(sag_id=cases[0],title='Multiple slots',user_ids=[users[0]],starts_at=start,ends_at=start+timedelta(hours=1))
first=calendar.create_event(payload,actor)['event']
second=calendar.create_event(payload.model_copy(update={'starts_at':start+timedelta(hours=2),'ends_at':start+timedelta(hours=3)}),actor)['event']
row=next(r for r in work.get_queue(users[0],100,0,actor)['items'] if r['sag_id']==cases[0])
assert row['future_planned_minutes']==120
assert {s['event_id'] for s in row['planned_slots']}=={first['id'],second['id']}
calendar.update_event(first['id'],payload.model_copy(update={'version':first['version'],
'starts_at':start+timedelta(hours=3),'ends_at':start+timedelta(hours=4)}),actor)
after=next(r for r in work.get_queue(users[0],100,0,actor)['items'] if r['sag_id']==cases[0])
assert len(after['planned_slots'])==2
original=next(s for s in row['planned_slots'] if s['event_id']==second['id'])
preserved=next(s for s in after['planned_slots'] if s['event_id']==second['id'])
assert original==preserved
def test_db_pause_resume_reuses_the_same_timer(planning_db):
import asyncio
from app.core.database import execute_query,execute_query_single
actor,users,cases=planning_db
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
timer=execute_query_single('''INSERT INTO tmodule_times(sag_id,medarbejder_id,description,original_hours,
worked_date,status,billable,aktiv_timer,start_tid,entry_status)
VALUES(%s,%s,'Synthetic timer',0.01,CURRENT_DATE,'pending',false,true,NOW(),'kladde') RETURNING id''',(cases[0],users[0]))['id']
queue=work.get_queue(users[0],100,0,actor)
paused=asyncio.run(work.pause_work(cases[0],work.WorkAction(version=queue['version'],timer_action=True),actor))
assert paused['item']['work_status']=='paused'
assert execute_query_single('SELECT aktiv_timer FROM tmodule_times WHERE id=%s',(timer,))['aktiv_timer'] is False
resumed=asyncio.run(work.resume_work(cases[0],work.WorkAction(version=paused['version'],timer_action=True),actor))
assert resumed['item']['work_status']=='working'
assert resumed['timer']['id']==timer
def test_db_capacity_counts_booked_work_once_and_flags_absence_overlap(planning_db):
from app.core.database import execute_query,execute_query_single
actor,users,cases=planning_db
start=datetime(2026,11,2,8,tzinfo=work.TZ)
execute_query('INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,0,%s,%s,0)',(users[0],time(8),time(16)),fetch=False)
work.add_assignee(cases[0],work.AssignmentInput(user_id=users[0]),actor)
queue=work.get_queue(users[0],100,0,actor)
work.change_queue(users[0],cases[0],work.QueueChange(version=queue['version'],estimated_minutes=600),actor)
calendar.create_event(calendar.EventInput(title='Capacity test',sag_id=cases[0],user_ids=[users[0]],starts_at=start+timedelta(hours=2),ends_at=start+timedelta(hours=4)),actor)
calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',starts_at=start+timedelta(hours=1),ends_at=start+timedelta(hours=3)),actor)
row=capacity.capacity(start,start+timedelta(hours=8),[users[0]],actor)['items'][0]
assert row['normal_minutes']==480 and row['busy_minutes']==180 and row['free_minutes']==300
assert row['queue_minutes']==480 and row['overload_minutes']==180
assert calendar.schedule(start,start+timedelta(hours=8),[users[0]],actor)['bookings'][0]['has_conflict'] is True
@pytest.mark.parametrize('action', ['transfer','remove'])
def test_db_transfer_offers_booking_choice_and_preserves_shared_participants(planning_db, action):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
start=datetime(2026,11,9,9,tzinfo=work.TZ)
for offset,participants in ((0,[users[0]]),(2,[users[0],users[2]])):
calendar.create_event(calendar.EventInput(title='Transfer reservation',sag_id=cases[0],
user_ids=participants,starts_at=start+timedelta(hours=offset),
ends_at=start+timedelta(hours=offset+1),confirm_conflicts=True),actor)
execute_query('UPDATE sag_sager SET ansvarlig_bruger_id=%s WHERE id=%s',(users[0],cases[0]),fetch=False)
old=execute_query('SELECT * FROM planner_allocations WHERE sag_id=%s AND user_id=%s ORDER BY id',(cases[0],users[0]))
body=dict(source_user_id=users[0],target_user_id=users[1],
source_version=work.get_queue(users[0],100,0,actor)['version'],
target_version=work.get_queue(users[1],100,0,actor)['version'])
with pytest.raises(HTTPException) as choice:
work.transfer(cases[0],work.TransferInput(**body),actor)
assert choice.value.status_code==409
assert choice.value.detail['code']=='future_bookings_choice'
assert choice.value.detail['booking_count']==2
payload=work.TransferInput(**body,future_bookings_action=action,
future_bookings=choice.value.detail['bookings'],confirm_conflicts=True)
work.transfer(cases[0],payload,actor)
changed=execute_query('SELECT * FROM planner_allocations WHERE id=ANY(%s) ORDER BY id',([r['id'] for r in old],))
for before,after in zip(old,changed):
assert after['starts_at']==before['starts_at'] and after['ends_at']==before['ends_at']
assert after['version']==before['version']+1
assert (after['user_id']==users[1] and after['deleted_at'] is None) if action=='transfer' else after['deleted_at'] is not None
assert execute_query_single('SELECT COUNT(*) AS n FROM planner_allocations WHERE sag_id=%s AND user_id=%s AND deleted_at IS NULL',(cases[0],users[2]))['n']==1
assert execute_query_single('SELECT ansvarlig_bruger_id FROM sag_sager WHERE id=%s',(cases[0],))['ansvarlig_bruger_id']==users[0]
assert {r['user_id'] for r in execute_query('SELECT user_id FROM sag_case_assignees WHERE sag_id=%s',(cases[0],))}=={users[1],users[2]}
def test_db_transfer_bookings_stale_conflict_and_audit_rollback(planning_db,monkeypatch):
actor,users,cases=planning_db
from app.core.database import execute_query,execute_query_single
start=datetime(2026,11,9,9,tzinfo=work.TZ)
calendar.create_event(calendar.EventInput(title='Move',sag_id=cases[0],user_ids=[users[0]],
starts_at=start,ends_at=start+timedelta(hours=1),confirm_conflicts=True),actor)
calendar.create_event(calendar.EventInput(title='Busy',user_ids=[users[1]],
starts_at=start,ends_at=start+timedelta(hours=1),confirm_conflicts=True),actor)
body=dict(source_user_id=users[0],target_user_id=users[1],
source_version=work.get_queue(users[0],100,0,actor)['version'],
target_version=work.get_queue(users[1],100,0,actor)['version'])
with pytest.raises(HTTPException) as choice:
work.transfer(cases[0],work.TransferInput(**body),actor)
snapshot=choice.value.detail['bookings']
with pytest.raises(HTTPException) as conflict:
work.transfer(cases[0],work.TransferInput(**body,future_bookings_action='transfer',future_bookings=snapshot),actor)
assert conflict.value.detail['confirm_conflicts']
original_audit=work.audit
def fail(*args,**kwargs):
raise RuntimeError('Audit rollback')
monkeypatch.setattr(work,'audit',fail)
payload=work.TransferInput(**body,future_bookings_action='transfer',future_bookings=snapshot,confirm_conflicts=True)
with pytest.raises(RuntimeError):
work.transfer(cases[0],payload,actor)
row=execute_query_single('SELECT * FROM planner_allocations WHERE id=%s',(snapshot[0]['id'],))
assert row['user_id']==users[0] and row['version']==snapshot[0]['version']
assert execute_query_single('SELECT version FROM planner_events WHERE id=%s',(row['event_id'],))['version']==snapshot[0]['event_version']
assert work.get_queue(users[0],100,0,actor)['version']==body['source_version']
assert not execute_query_single('SELECT 1 FROM sag_case_assignees WHERE sag_id=%s AND user_id=%s',(cases[0],users[1]))
monkeypatch.setattr(work,'audit',original_audit)
execute_query('UPDATE planner_events SET version=version+1 WHERE id=%s',(row['event_id'],),fetch=False)
with pytest.raises(HTTPException) as stale:
work.transfer(cases[0],payload,actor)
assert stale.value.status_code==409 and 'ændret' in stale.value.detail
def test_absence_whole_days_are_visible_and_follow_copenhagen_dst():
placeholder=datetime(2026,10,1,tzinfo=work.TZ)
row=calendar.AbsenceInput(user_id=1,kind='vacation',starts_at=placeholder,
ends_at=placeholder+timedelta(hours=1),whole_day_start=date(2026,10,24),whole_day_end=date(2026,10,26))
assert row.starts_at.utcoffset()==timedelta(hours=2)
assert row.ends_at.utcoffset()==timedelta(hours=1)
safe=calendar.visible_absence(dict(id=1,user_id=1,kind='vacation',note='Private',
starts_at=row.starts_at,ends_at=row.ends_at),{'id':1})
assert safe['all_day'] and safe['display_start']=='2026-10-24' and safe['display_end']=='2026-10-27'
safe=calendar.visible_absence(dict(id=1,user_id=1,kind='vacation',note='Private',
starts_at=row.starts_at.replace(hour=8),ends_at=row.ends_at.replace(hour=16)),{'id':2})
assert safe['kind']=='unavailable' and safe['note'] is None and safe['all_day']
assert safe['display_end']=='2026-10-28'
def test_db_absence_report_permissions_scope_masking_overlap_and_pagination(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
from datetime import timezone
monday=datetime(2026,11,9,tzinfo=work.TZ)
for uid in users:
execute_query('DELETE FROM planner_work_schedules WHERE user_id=%s',(uid,),fetch=False)
for weekday in range(5):
execute_query("INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,%s,'08:00','16:00',30)",(uid,weekday),fetch=False)
# Two overlapping periods must count as two rows, but only one day in totals.
for kind,start,end in [('vacation',monday,monday+timedelta(days=1)),
('sickness',monday.replace(hour=9),monday.replace(hour=12))]:
calendar.create_absence(calendar.AbsenceInput(user_id=users[1],kind=kind,
starts_at=start,ends_at=end,note='Never export this'),actor)
calendar.create_absence(calendar.AbsenceInput(user_id=users[2],kind='vacation',
starts_at=monday,ends_at=monday+timedelta(days=1)),actor)
reader={'id':users[0],'permissions':['planner.view','cases.view','planner.view_team'],
'_planner_team_scope_ids':{users[1]}}
with pytest.raises(HTTPException) as denied:
calendar.absence_report(monday.date(),monday.date(),None,50,0,reader)
assert denied.value.status_code==403
reader['permissions'].append('planner.absence_report')
result=calendar.absence_report(monday.date(),monday.date(),None,1,0,reader)
assert result['total']==2 and len(result['items'])==1
assert {r['id'] for r in result['employees']}=={users[0],users[1]}
assert result['totals']['planned_minutes']==450 and result['totals']['planned_days']==1
assert result['items'][0]['kind']=='unavailable' and result['items'][0]['kind_label']=='Fravær'
assert 'Never export this' not in str(result)
assert 'note' not in result['items'][0]
assert len(result['type_summary'])==1
masked=result['type_summary'][0]
assert masked['kind']=='unavailable' and masked['kind_label']=='Fravær'
assert masked['planned_minutes']==450 and masked['periods']==2
assert {r['user_id'] for r in masked['employees']}=={users[1]}
next_page=calendar.absence_report(monday.date(),monday.date(),None,1,1,reader)
assert next_page['items'][0]['id']!=result['items'][0]['id']
with pytest.raises(HTTPException) as denied:
calendar.absence_report(monday.date(),monday.date(),users[2],50,0,reader)
assert denied.value.status_code==403
reader['permissions'].append('planner.absence_reason')
result=calendar.absence_report(monday.date(),monday.date(),users[1],50,0,reader)
assert {r['kind_label'] for r in result['items']}=={'Ferie','Sygdom'}
grouped=calendar.absence_report(monday.date(),monday.date(),users[1],1,0,reader,group_by='type')
assert [r['kind_label'] for r in grouped['type_summary']]==['Ferie','Sygdom']
assert grouped['type_summary'][0]['planned_minutes']==450
assert grouped['type_summary'][1]['planned_minutes']==180
assert grouped['items'][0]['kind']=='vacation'
next_group=calendar.absence_report(monday.date(),monday.date(),users[1],1,1,reader,group_by='type')
assert next_group['items'][0]['kind']=='sickness'
assert next_group['type_summary']==grouped['type_summary']
# A range ending before the periods must contain no rows.
result=calendar.absence_report(date(2026,11,1),date(2026,11,8),users[1],50,0,reader)
assert result['total']==0 and result['totals']['planned_minutes']==0
def test_db_absence_report_keeps_actual_time_separate_and_filters_worked_dates(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query
for weekday in (0,1):
execute_query("INSERT INTO planner_work_schedules(user_id,weekday,start_time,end_time,break_minutes) VALUES(%s,%s,'08:00','16:00',30)",(users[0],weekday),fetch=False)
start=datetime(2020,1,6,tzinfo=work.TZ)
absence=calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',
starts_at=start,ends_at=start+timedelta(days=2)),actor)['absence']
before=calendar.absence_report(date(2020,1,7),date(2020,1,7),users[0],50,0,actor)
assert before['totals']=={'planned_minutes':450,'planned_days':1,'actual_minutes':0}
assert not before['items'][0]['registered']
calendar.register_absence(absence['id'],absence['version'],actor)
after=calendar.absence_report(date(2020,1,7),date(2020,1,7),users[0],50,0,actor)
assert after['totals']=={'planned_minutes':450,'planned_days':1,'actual_minutes':450}
assert after['items'][0]['registered']
assert after['total']==1
def test_db_team_calendar_exposes_absence_periods_with_masked_reasons(planning_db):
actor,users,cases=planning_db
from app.modules.planner.backend.router import period
start=datetime(2026,11,9,tzinfo=work.TZ)
for uid in users:
calendar.create_absence(calendar.AbsenceInput(user_id=uid,kind='vacation',
starts_at=start,ends_at=start+timedelta(days=2),note='Private absence note'),actor)
reader={'id':users[0],'permissions':['planner.view','planner.view_team','cases.view'],
'_planner_team_scope_ids':{users[1]}}
result=period(start,start+timedelta(days=7),users[:2],reader)
assert len(result['absences'])==2
assert {r['user_id'] for r in result['absences']}==set(users[:2])
assert next(r for r in result['absences'] if r['user_id']==users[0])['kind_label']=='Ferie'
safe=next(r for r in result['absences'] if r['user_id']==users[1])
assert safe['kind_label']=='Fravær' and safe['kind']=='unavailable'
assert 'Private absence note' not in str(result['absences'])
assert all('note' not in r for r in result['absences'])
reader['permissions'].append('planner.absence_reason')
assert all(r['kind_label']=='Ferie' for r in period(start,start+timedelta(days=7),users[:2],reader)['absences'])
assert not period(start+timedelta(days=2),start+timedelta(days=3),users[:2],reader)['absences']
with pytest.raises(HTTPException) as denied:
period(start,start+timedelta(days=7),[users[2]],reader)
assert denied.value.status_code==403
def test_db_duplicate_absence_type_rejected_for_create_and_edit(planning_db):
actor,users,cases=planning_db
from app.core.database import execute_query_single
start=datetime(2026,11,9,8,tzinfo=work.TZ)
def payload(a,b,uid=users[0],kind='vacation',version=None):
return calendar.AbsenceInput(user_id=uid,kind=kind,starts_at=a,ends_at=b,version=version)
first=calendar.create_absence(payload(start,start+timedelta(hours=8)),actor)['absence']
for a,b in [(start,start+timedelta(hours=8)),(start+timedelta(hours=1),start+timedelta(hours=9)),
(start-timedelta(hours=1),start+timedelta(hours=1))]:
with pytest.raises(HTTPException) as denied:
calendar.create_absence(payload(a,b),actor)
assert denied.value.status_code==409 and denied.value.detail['code']=='absence_overlap'
# Touching periods and another employee's holiday remain valid.
next_period=calendar.create_absence(payload(start+timedelta(hours=8),start+timedelta(hours=10)),actor)['absence']
calendar.create_absence(payload(start,start+timedelta(hours=8),users[1]),actor)
calendar.update_absence(first['id'],payload(start,start+timedelta(hours=7),version=first['version']),actor)
with pytest.raises(HTTPException) as denied:
calendar.update_absence(next_period['id'],payload(start+timedelta(hours=1),start+timedelta(hours=9),version=next_period['version']),actor)
assert denied.value.status_code==409
assert execute_query_single('SELECT starts_at FROM planner_absences WHERE id=%s',(next_period['id'],))['starts_at']==next_period['starts_at']
calendar.cancel_absence(next_period['id'],next_period['version'],actor)
calendar.create_absence(payload(start+timedelta(hours=7),start+timedelta(hours=10)),actor)
def test_db_concurrent_duplicate_holidays_have_one_winner(planning_db):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
actor,users,cases=planning_db
start=datetime(2026,11,9,8,tzinfo=work.TZ)
barrier=Barrier(2)
def create():
barrier.wait(timeout=10)
try:
calendar.create_absence(calendar.AbsenceInput(user_id=users[0],kind='vacation',starts_at=start,ends_at=start+timedelta(hours=8)),actor)
return 201
except HTTPException as error:
return error.status_code
with ThreadPoolExecutor(max_workers=2) as pool:
assert sorted(pool.map(lambda _:create(),range(2)))==[201,409]