Compare commits

...

18 Commits

Author SHA1 Message Date
Christian
47afd41877 release: v2.8.22 2026-11-03 09:08:51 +01:00
Christian
1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00
Christian
3b61c46ea4 Refactor code structure for improved readability and maintainability; remove redundant code blocks and optimize function calls. 2026-10-20 07:18:57 +02:00
Christian
85e019783b Add comprehensive test coverage for case creation, assignment, and remote recovery
- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads.
- Added tests for v5 assignment functionality, validating employee identification and group assignment.
- Implemented tests for remote recovery scenarios, including handling network failures and response validation.
- Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation.
- Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions.
- Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
2026-10-15 12:17:09 +02:00
Christian
1de084864d Simplify time queue into compact review rows and one action bar 2026-10-09 01:29:12 +02:00
Christian
ba463ced4e Add reviewed approval and billing lifecycle to time queue 2026-10-09 01:21:12 +02:00
Christian
239d75ff06 Simplify time queue layout to match Nordic Top design plan 2026-10-09 00:52:06 +02:00
Christian
cdb9d8c178 Make economy time queue actionable and show case tags 2026-10-09 00:45:21 +02:00
Christian
8012423844 Persist rejected case tag suggestions and prevent removed tag resurrection 2026-10-08 23:18:36 +02:00
Christian
be1a899ccb Show concrete billing prediction in V5 time capture 2026-10-08 23:06:55 +02:00
Christian
8a142dccf2 Reevaluate automatic coverage on time tag events 2026-10-08 22:41:01 +02:00
Christian
b499b2dfea Derive automatic billing rules from saved resource coverage 2026-10-08 22:23:14 +02:00
Christian
4a06ed8e00 Make V5 quick time capture automatic and keep billing choices in time tab 2026-10-08 21:36:42 +02:00
Christian
16fca2c8f1 Add audited workflow activation to CRM customer card 2026-10-08 21:09:25 +02:00
Christian
5eece0d704 Align workflow pages with Hub design and permission-aware menu 2026-10-08 20:34:18 +02:00
Christian
b398278b13 Add reversible opt-in workflow engine and billing integration 2026-10-08 18:00:47 +02:00
Christian
4871dd2d9e Add comprehensive test coverage for various modules
- Introduced tests for mail templates including ranking, rendering, and attachment handling.
- Added tests for time billing selection and approval processes, ensuring proper validation and state management.
- Implemented tests for case types in JavaScript, focusing on loading and editing functionality.
- Created tests for contact mail AI integration, ensuring proper parsing and error handling.
- Developed tests for dark theme CSS contrast and loading order.
- Added tests for invoice fee service, covering various scenarios including exemptions and invalid inputs.
- Implemented vendor resource tests, ensuring proper file handling and query validation.
2026-10-08 08:58:02 +02:00
Christian
3387f6c475 feat: enhance solution management with customer selection and improve call logging URLs
- Added customer search functionality in the solution management editor, allowing users to select a customer.
- Updated the API endpoint for incoming call logging to use the local recipient extension.
- Implemented a new case number navigation feature in the global search.
- Modified case creation logic to include relations and adjust module visibility.
- Added tests for new features including customer selection in solutions and call logging URL changes.
2026-10-03 14:06:51 +02:00
402 changed files with 33982 additions and 1728 deletions

View File

@ -3,6 +3,9 @@
# =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk
# Remote control remains disabled until migrated; uses the existing Hub on local network/VPN.
REMOTE_ENABLED=false
REMOTE_LOOKUP_KEY=
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
# Database credentials (bruges af docker-compose)

2
.gitignore vendored
View File

@ -28,4 +28,6 @@ htmlcov/
.coverage
.pytest_cache/
.mypy_cache/
# Generated Graphify graphs and caches (keep local only)
graphify-out/
RELEASE_NOTES_v2.2.38.md

View File

@ -0,0 +1,22 @@
# BMC Hub v2.8.22
## Arbejdsgange, sager og tid
- Tilføjer den reversible workflow-motor med kontrolleret aktivering, preview og revisionsspor for afregning.
- Udvider sagsarbejdsrummet, tidsregistrering og planlægning med tydeligere kapacitet, opgaver og opfølgning.
- Tilføjer sikker fjernstyring og forbedrede sagskommandoer uden ændring af eksisterende API-kontrakter.
## Kunder, e-mail og drift
- Forbedrer kunde- og kontaktarbejdsgange, e-mailidentitet og routing samt Drift-filtersikkerhed.
- Tilføjer planlagt onboarding/offboarding på kontaktkortet med eksplicit sagsoprettelse og audit-spor.
- Udvider hardware-intake, leverandørdata og økonomiske integrationsvisninger.
## Database
Kør migrationerne `1053` til `1076` i numerisk rækkefølge før eller sammen med opstart af versionen. Migration `1076_contact_lifecycle.sql` opretter kun nye tabeller og indeks.
## Validering
- Python: `986 passed, 103 skipped` med workflow- og remote-feature-flags slået fra i unit-testmiljøet.
- JavaScript: `86 passed`.

View File

@ -1 +1 @@
2.8.21
2.8.22

View File

@ -5,8 +5,8 @@
.ct-shell{max-width:1500px;margin:auto}.ct-hero{border-radius:24px;padding:2rem;background:linear-gradient(120deg,#082f49,#0f4c75 55%,#2563eb);color:#fff;box-shadow:0 22px 55px rgba(8,47,73,.22)}
.ct-panel{background:var(--bs-body-bg);border:1px solid rgba(15,76,117,.14);border-radius:18px;box-shadow:0 10px 28px rgba(15,76,117,.06)}
.ct-kpi{border-radius:16px;padding:1.1rem;background:linear-gradient(145deg,rgba(15,76,117,.08),rgba(37,99,235,.03));height:100%}.ct-kpi strong{font-size:1.7rem}.ct-delta.positive{color:#16803c}.ct-delta.negative{color:#dc3545}
.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:#dcfce7;color:#166534}.ct-status.blocked{background:#fee2e2;color:#991b1b}
.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#64748b}.ct-empty{padding:3rem;text-align:center;color:#64748b}
.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:var(--theme-success-bg, #dcfce7);color:var(--theme-success-text, #166534)}.ct-status.blocked{background:var(--theme-danger-bg, #fee2e2);color:var(--theme-danger-text, #991b1b)}
.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--theme-muted, #64748b)}.ct-empty{padding:3rem;text-align:center;color:var(--theme-muted, #64748b)}
</style>
{% endblock %}
{% block content %}

View File

@ -91,7 +91,7 @@
/* badges */
.badge-link { display:inline-flex; align-items:center; gap:.3rem; font-size:.78rem; padding:.2rem .55rem; border-radius:5px; font-weight:500; white-space:nowrap; }
.badge-hw { background:rgba(15,76,117,.12); color:#0f4c75; }
.badge-hw { background:rgba(15,76,117,.12); color:var(--theme-primary-text, #0f4c75); }
.badge-cust{ background:rgba(40,167,69,.12); color:#1a6e30; }
.badge-cont{ background:rgba(255,140,0,.13); color:#b36200; }
.badge-sag { background:rgba(111,66,193,.13); color:#4b1d8c; }
@ -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

@ -142,7 +142,7 @@
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
padding: 0.7rem 0.85rem;
background: #fff;
background: var(--theme-surface, #fff);
}
</style>
{% endblock %}
@ -5426,7 +5426,7 @@ function showLoadingOverlay(message = 'Behandler...') {
z-index: 9999;
`;
overlay.innerHTML = `
<div style="background: white; padding: 2rem; border-radius: 12px; text-align: center;">
<div style="background: var(--theme-surface, white); padding: 2rem; border-radius: 12px; text-align: center;">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>

View File

@ -58,7 +58,7 @@
.status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); }
.status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); }
.status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); }
.status-posted { background: rgba(22, 101, 52, 0.14); color: #166534; }
.status-posted { background: rgba(22, 101, 52, 0.14); color: var(--theme-success-text, #166534); }
.status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); }
.table thead th {

View File

@ -0,0 +1,213 @@
"""Additive, customer-scoped contact onboarding and offboarding API."""
from datetime import date
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.core.auth_dependencies import require_any_permission
from app.core.case_visibility import hidden_case_ids, require_case
from app.core.database import atomic_transaction, execute_query, execute_query_single
router = APIRouter()
read_access = require_any_permission('cases.view', 'tickets.view')
edit_access = require_any_permission('cases.edit', 'tickets.edit')
EventType = Literal['onboarding', 'offboarding']
class LifecycleCreate(BaseModel):
customer_id: int = Field(gt=0)
event_type: EventType
event_date: date
lead_days: int = Field(default=14, ge=0, le=3650)
auto_create: bool = False
template_id: Optional[int] = Field(default=None, gt=0)
responsible_user_id: Optional[int] = Field(default=None, gt=0)
parent_case_id: Optional[int] = Field(default=None, gt=0)
class LifecycleUpdate(BaseModel):
customer_id: Optional[int] = Field(default=None, gt=0)
event_date: Optional[date] = None
lead_days: Optional[int] = Field(default=None, ge=0, le=3650)
auto_create: Optional[bool] = None
template_id: Optional[int] = Field(default=None, gt=0)
responsible_user_id: Optional[int] = Field(default=None, gt=0)
parent_case_id: Optional[int] = Field(default=None, gt=0)
def _ready():
if not (execute_query_single("SELECT to_regclass('public.contact_lifecycle_events') AS relation") or {}).get('relation'):
raise HTTPException(503, detail='Onboarding/offboarding afventer databasemigration 1076')
def _contact(contact_id):
row = execute_query_single('SELECT id FROM contacts WHERE id=%s', (contact_id,))
if not row:
raise HTTPException(404, detail='Kontaktpersonen findes ikke')
def _membership(contact_id, customer_id):
if not execute_query_single('SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s',
(contact_id, customer_id)):
raise HTTPException(400, detail='Kunden er ikke tilknyttet kontaktpersonen')
def _has(user, *permissions):
return bool(user.get('is_superadmin') or set(permissions).intersection(user.get('permissions') or []))
def _validate(row, user):
_membership(row['contact_id'], row['customer_id'])
if row['auto_create'] and not _has(user, 'cases.create', 'tickets.create'):
raise HTTPException(403, detail='Du mangler rettighed til at oprette sager')
if row.get('responsible_user_id') and not execute_query_single(
'SELECT user_id FROM users WHERE user_id=%s AND is_active=TRUE', (row['responsible_user_id'],)
):
raise HTTPException(400, detail='Den ansvarlige bruger er ikke aktiv')
if row.get('template_id'):
if not _has(user, 'templates.run'):
raise HTTPException(403, detail='Du mangler rettighed til at anvende skabeloner')
if not execute_query_single('''SELECT id FROM task_templates WHERE id=%s
AND is_active=TRUE AND deleted_at IS NULL AND category=%s
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=%s))''',
(row['template_id'], row['event_type'], row['customer_id'])):
raise HTTPException(400, detail='Skabelonen er ikke tilgængelig for kunden og forløbstypen')
from app.contacts.backend.lifecycle_service import find_parent_candidates
candidates = find_parent_candidates(row['contact_id'], row['customer_id'], row['event_type'])
hidden = hidden_case_ids(user)
candidates = [c for c in candidates if c['id'] not in hidden]
# A generated case must never become its own parent during later edits.
candidates = [c for c in candidates if c['id'] != row.get('case_id')]
if row.get('parent_case_id') and row['parent_case_id'] not in [c['id'] for c in candidates]:
# Parent can have been closed after creation: retaining it is valid; changing it is not.
if not row.get('case_id'):
raise HTTPException(400, detail='Hovedsagen skal være en åben sag for samme kontakt, kunde og type')
return candidates
def _serialize(row):
from app.contacts.backend.lifecycle_service import scheduled_at
result = dict(row)
result['scheduled_at'] = scheduled_at(row['event_date'], row['lead_days']).isoformat()
return result
def _find_event(contact_id, event_id, lock=False):
row = execute_query_single('SELECT * FROM contact_lifecycle_events WHERE contact_id=%s AND id=%s' +
(' FOR UPDATE' if lock else ''), (contact_id, event_id))
if not row:
raise HTTPException(404, detail='Forløbet findes ikke')
return row
@router.get('/contacts/{contact_id}/lifecycle')
def list_lifecycle(contact_id: int, current_user: dict = Depends(read_access)):
_ready()
_contact(contact_id)
companies = execute_query('''SELECT c.id,c.name FROM customers c JOIN contact_companies cc
ON cc.customer_id=c.id WHERE cc.contact_id=%s ORDER BY c.name''', (contact_id,)) or []
templates = []
if _has(current_user, 'templates.view', 'templates.run'):
templates = execute_query('''SELECT id,name,category,customer_id,template_type FROM task_templates
WHERE deleted_at IS NULL AND is_active=TRUE AND category IN ('onboarding','offboarding')
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=ANY(%s)))
ORDER BY name''', ([c['id'] for c in companies],)) or []
hidden = hidden_case_ids(current_user)
items = []
for row in execute_query(
'SELECT * FROM contact_lifecycle_events WHERE contact_id=%s ORDER BY event_date DESC,id DESC',
(contact_id,)) or []:
item = _serialize(row)
for key in ('case_id', 'parent_case_id'):
if item.get(key) in hidden:
item[key] = None
items.append(item)
return {'items': items, 'companies': companies, 'templates': templates,
'can_edit': _has(current_user, 'cases.edit', 'tickets.edit'),
'users': execute_query('''SELECT user_id AS id,COALESCE(NULLIF(full_name,''),username) AS name
FROM users WHERE is_active=TRUE ORDER BY name''') or []}
@router.get('/contacts/{contact_id}/lifecycle/preview')
def preview_lifecycle(contact_id: int, customer_id: int = Query(gt=0),
event_type: EventType = Query(), event_date: date = Query(),
lead_days: int = Query(default=14, ge=0, le=3650),
current_user: dict = Depends(read_access)):
_ready()
_contact(contact_id)
_membership(contact_id, customer_id)
from app.contacts.backend.lifecycle_service import find_parent_candidates, scheduled_at
candidates = find_parent_candidates(contact_id, customer_id, event_type)
hidden = hidden_case_ids(current_user)
candidates = [c for c in candidates if c['id'] not in hidden]
return {'scheduled_at': scheduled_at(event_date, lead_days).isoformat(), 'candidates': candidates,
'parent_case_id': candidates[0]['id'] if len(candidates) == 1 else None,
'needs_parent': len(candidates) > 1}
@router.post('/contacts/{contact_id}/lifecycle', status_code=201)
def create_lifecycle(contact_id: int, payload: LifecycleCreate, current_user: dict = Depends(edit_access)):
_ready()
_contact(contact_id)
row = dict(payload.model_dump(), contact_id=contact_id)
candidates = _validate(row, current_user)
status = 'draft' if not row['auto_create'] else (
'needs_parent' if len(candidates) > 1 and not row['parent_case_id'] else 'scheduled')
columns = list(row)
result = execute_query_single(f'''INSERT INTO contact_lifecycle_events
({','.join(columns)},status,created_by) VALUES ({','.join(['%s'] * len(columns))},%s,%s) RETURNING *''',
tuple(row.values()) + (status, current_user['id']))
return _serialize(result)
@router.patch('/contacts/{contact_id}/lifecycle/{event_id}')
def update_lifecycle(contact_id: int, event_id: int, payload: LifecycleUpdate,
current_user: dict = Depends(edit_access)):
_ready()
updates = payload.model_dump(exclude_unset=True)
for required in ('customer_id', 'event_date', 'lead_days', 'auto_create'):
if required in updates and updates[required] is None:
raise HTTPException(422, detail=f'{required} må ikke være tom')
with atomic_transaction():
previous = _find_event(contact_id, event_id, lock=True)
if previous.get('case_id'):
require_case(current_user, previous['case_id'])
if previous['status'] == 'cancelled':
raise HTTPException(409, detail='Et annulleret forløb kan ikke ændres; opret et nyt')
row = dict(previous, **updates)
if row.get('case_id') and any(row.get(k) != previous.get(k) for k in
('customer_id', 'template_id', 'parent_case_id', 'responsible_user_id')):
raise HTTPException(409, detail='Kunde, skabelon, hovedsag og ansvarlig ændres på den oprettede sag')
status = previous['status']
last_error = previous.get('last_error')
if row.get('case_id'):
if row['event_date'] != previous['event_date']:
from app.contacts.backend.lifecycle_service import update_created_case_date
result = update_created_case_date(row, previous['event_date'], current_user['id'])
status, last_error = result['status'], result.get('last_error')
else:
candidates = _validate(row, current_user)
status = 'draft' if not row['auto_create'] else (
'needs_parent' if len(candidates) > 1 and not row.get('parent_case_id') else 'scheduled')
last_error = None
if row['auto_create']:
# The last user authorizing automation is the actor whose rights
# are checked by the worker, including when a disabled user is replaced.
updates['created_by'] = current_user['id']
updates.update(status=status, last_error=last_error)
columns = list(updates)
result = execute_query_single(f'''UPDATE contact_lifecycle_events SET
{','.join(c + '=%s' for c in columns)},updated_at=NOW() WHERE id=%s RETURNING *''',
tuple(updates.values()) + (event_id,))
return _serialize(result)
@router.post('/contacts/{contact_id}/lifecycle/{event_id}/cancel')
def cancel_lifecycle(contact_id: int, event_id: int, current_user: dict = Depends(edit_access)):
_ready()
with atomic_transaction():
_find_event(contact_id, event_id, lock=True)
row = execute_query_single('''UPDATE contact_lifecycle_events SET status='cancelled',auto_create=FALSE,
last_error=NULL,updated_at=NOW() WHERE id=%s RETURNING *''', (event_id,))
return _serialize(row)

View File

@ -0,0 +1,199 @@
"""Transactional contact lifecycle scheduling using Hub's existing case contracts."""
import html
import json
import logging
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from fastapi import HTTPException
from app.core.database import atomic_transaction, execute_query, execute_query_single
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Europe/Copenhagen")
LABELS = {"onboarding": "Onboarding", "offboarding": "Offboarding"}
def scheduled_at(event_date, lead_days=14):
if isinstance(event_date, str):
event_date = date.fromisoformat(event_date)
return datetime.combine(event_date - timedelta(days=lead_days), time(8), TZ)
def _deadline(event_date):
"""Existing case timestamps store UTC without timezone information."""
if isinstance(event_date, str):
event_date = date.fromisoformat(event_date)
return datetime.combine(event_date, time(17), TZ).astimezone(timezone.utc).replace(tzinfo=None)
def closed_statuses():
values = {"lukket", "løst", "afsluttet", "closed", "resolved", "done", "annulleret", "cancelled", "canceled"}
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
if row and row.get("value"):
try:
options = row["value"] if isinstance(row["value"], list) else json.loads(row["value"])
values.update(str(item["value"]).strip().lower() for item in options
if isinstance(item, dict) and item.get("is_closed") and item.get("value"))
except (TypeError, ValueError):
logger.warning("Ignoring invalid case_statuses configuration")
return sorted(values)
def find_parent_candidates(contact_id, customer_id, event_type):
"""Only explicit type/tag classification and direct person linkage qualify."""
return execute_query("""SELECT s.id,s.titel,s.status,s.customer_id
FROM sag_sager s
WHERE s.customer_id=%s AND s.deleted_at IS NULL
AND LOWER(TRIM(s.status)) <> ALL(%s)
AND EXISTS(SELECT 1 FROM sag_kontakter k WHERE k.sag_id=s.id
AND k.contact_id=%s AND k.deleted_at IS NULL)
AND (LOWER(s.type)=%s OR LOWER(s.template_key)=%s
OR EXISTS(SELECT 1 FROM sag_tags t WHERE t.sag_id=s.id
AND t.deleted_at IS NULL AND LOWER(TRIM(t.tag_navn))=%s))
AND NOT EXISTS(SELECT 1 FROM sag_relationer r WHERE r."målsag_id"=s.id
AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL)
ORDER BY s.id DESC""",
(customer_id, closed_statuses(), contact_id, event_type, event_type, event_type)) or []
def update_created_case_date(lifecycle, previous_event_date, actor_id):
"""Called inside the API transaction; never changes the lifecycle row itself."""
if not lifecycle.get("case_id") or lifecycle["event_date"] == previous_event_date:
return {"status": lifecycle.get("status", "created"), "last_error": lifecycle.get("last_error")}
from app.core.case_visibility import require_case
from app.core.auth_service import AuthService
actor = {"id": actor_id, "permissions": AuthService.get_user_permissions(actor_id)}
require_case(actor, lifecycle["case_id"])
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE",
(lifecycle["case_id"],))
if not case or case.get("deleted_at") or str(case["status"]).strip().lower() in closed_statuses():
return {"status": "manual_review", "last_error": "Datoen er ændret. Sagen er afsluttet eller fjernet; kontrollér deadline manuelt."}
execute_query("UPDATE sag_sager SET deadline=%s,updated_at=NOW() WHERE id=%s",
(_deadline(lifecycle["event_date"]), case["id"]))
_comment(case["id"], f"{LABELS[lifecycle['event_type']]}dato ændret fra {previous_event_date} til {lifecycle['event_date']}. Deadline opdateret.")
return {"status": "created", "last_error": None}
def _comment(case_id, message):
execute_query("""INSERT INTO sag_kommentarer
(sag_id,forfatter,indhold,er_intern,er_system_besked)
VALUES(%s,'BMC Hub',%s,TRUE,TRUE)""", (case_id, html.escape(message)))
def _actor(event):
from app.core.auth_service import AuthService
user = execute_query_single("SELECT user_id,is_active,is_superadmin,full_name FROM users WHERE user_id=%s",
(event["created_by"],))
if not user or not user["is_active"]:
raise HTTPException(403, "Forløbets opretter er ikke længere aktiv. Gem forløbet med en aktiv bruger.")
user["id"] = user["user_id"]
user["permissions"] = AuthService.get_user_permissions(user["id"])
if not user["is_superadmin"]:
permissions = set(user["permissions"])
if not permissions.intersection({"cases.create", "tickets.create"}):
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette sager.")
if event.get("template_id") and "templates.run" not in permissions:
raise HTTPException(403, "Forløbets opretter mangler rettighed til at bruge skabelonen.")
return user
async def _create_case(event):
from app.core.case_visibility import require_case
actor = _actor(event)
if not execute_query_single("SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s",
(event["contact_id"], event["customer_id"])):
raise HTTPException(409, "Kontakten er ikke længere tilknyttet kunden. Vælg kunde igen.")
candidates = find_parent_candidates(event["contact_id"], event["customer_id"], event["event_type"])
parent_id = event.get("parent_case_id")
if parent_id and parent_id not in {row["id"] for row in candidates}:
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
updated_at=NOW() WHERE id=%s""",
("Den valgte hovedsag kan ikke længere bruges. Vælg hovedsag igen.", event["id"]))
return False
if not parent_id and len(candidates) > 1:
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
updated_at=NOW() WHERE id=%s""",
("Flere åbne sager matcher. Vælg hovedsag på kontaktkortet.", event["id"]))
return False
parent_id = parent_id or (candidates[0]["id"] if candidates else None)
if parent_id:
require_case(actor, parent_id)
if not actor["is_superadmin"] and not set(actor["permissions"]).intersection({"cases.edit", "tickets.edit"}):
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette undersager.")
# Lock and recheck before adding a relation; a concurrently closed parent cannot be used.
parent = execute_query_single("SELECT status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE", (parent_id,))
if not parent or parent.get("deleted_at") or str(parent["status"]).strip().lower() in closed_statuses():
raise HTTPException(409, "Hovedsagen er afsluttet. Vælg hovedsag igen.")
contact = execute_query_single("SELECT first_name,last_name FROM contacts WHERE id=%s", (event["contact_id"],))
if not contact:
raise HTTPException(409, "Kontakten findes ikke længere.")
name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
label = LABELS[event["event_type"]]
responsible = event.get("responsible_user_id")
if responsible and not execute_query_single("SELECT user_id FROM users WHERE user_id=%s AND is_active", (responsible,)):
raise HTTPException(409, "Den ansvarlige bruger er ikke længere aktiv. Vælg ansvarlig igen.")
case = execute_query_single("""INSERT INTO sag_sager
(titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,created_by_user_id,deadline)
VALUES(%s,%s,'ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id""",
(f"{label}: {name}"[:255], f"{label} for {name}. Dato: {event['event_date']}.\nOprettet automatisk fra kontaktkortet.",
event["customer_id"], responsible, actor["id"], _deadline(event["event_date"])))
case_id = case["id"]
execute_query("INSERT INTO sag_kontakter(sag_id,contact_id,role,is_primary) VALUES(%s,%s,'Kontakt',TRUE)",
(case_id, event["contact_id"]))
execute_query("INSERT INTO sag_tags(sag_id,tag_navn) VALUES(%s,%s)", (case_id, event["event_type"]))
if parent_id:
execute_query('INSERT INTO sag_relationer(kilde_sag_id,"målsag_id",relationstype) VALUES(%s,%s,\'undersag\')',
(parent_id, case_id))
if event.get("template_id"):
if not execute_query_single("""SELECT id FROM task_templates WHERE id=%s
AND category=%s AND is_active AND deleted_at IS NULL""",
(event["template_id"], event["event_type"])):
raise HTTPException(409, "Skabelonen er ændret eller deaktiveret. Vælg skabelon igen.")
from app.modules.task_templates.backend.models import TemplateRunRequest
from app.modules.task_templates.backend.router import run_template_for_case
await run_template_for_case(case_id, TemplateRunRequest(template_id=event["template_id"], start_date=event["event_date"]), actor)
execute_query("""UPDATE contact_lifecycle_events SET case_id=%s,parent_case_id=%s,
status='created',last_error=NULL,updated_at=NOW() WHERE id=%s""",
(case_id, parent_id, event["id"]))
return True
async def process_due_lifecycles(now=None):
"""Row locks and a single transaction encompass case, relation and template writes."""
ready = execute_query_single("SELECT to_regclass('contact_lifecycle_events') IS NOT NULL AS ready")
if not ready or not ready["ready"]:
return 0
now = now or datetime.now(timezone.utc)
if now.tzinfo is None:
raise ValueError("now must include timezone")
rows = execute_query("""SELECT id FROM contact_lifecycle_events
WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error')
AND ((event_date-lead_days)+TIME '08:00') AT TIME ZONE 'Europe/Copenhagen' <= %s
ORDER BY event_date,id LIMIT 100""", (now,)) or []
created = 0
for row in rows:
try:
with atomic_transaction():
event = execute_query_single("SELECT * FROM contact_lifecycle_events WHERE id=%s FOR UPDATE SKIP LOCKED", (row["id"],))
if not event or not event["auto_create"] or event.get("case_id") or event["status"] not in {"scheduled", "error"}:
continue
if scheduled_at(event["event_date"], event["lead_days"]) > now:
continue
did_create = await _create_case(event)
created += int(did_create)
except Exception as exc:
logger.exception("Contact lifecycle %s failed", row["id"])
message = str(exc.detail) if isinstance(exc, HTTPException) else "Sagen kunne ikke oprettes. Systemet prøver igen; kontakt administrator hvis fejlen fortsætter."
execute_query("""UPDATE contact_lifecycle_events SET status='error',last_error=%s,updated_at=NOW()
WHERE id=%s AND case_id IS NULL AND status IN ('scheduled','error')""", (message, row["id"]))
return created
async def check_contact_lifecycles():
"""Isolate lifecycle failures from other scheduled jobs."""
try:
return await process_due_lifecycles()
except Exception:
logger.exception("Contact lifecycle scheduler failed")
return 0

View File

@ -34,6 +34,8 @@ from email.utils import parseaddr
logger = logging.getLogger(__name__)
router = APIRouter()
from app.contacts.backend.lifecycle import router as lifecycle_router
router.include_router(lifecycle_router)
class ContactCreate(BaseModel):
@ -243,12 +245,12 @@ def _company_from_email_body(body: str, contact_name: str = "") -> dict:
@router.post("/contacts/analyze-email")
async def analyze_email_for_new_contact(file: UploadFile = File(...)):
async def analyze_email_for_new_contact(file: UploadFile = File(...), use_ai: bool = False):
"""Return editable contact/company candidates without creating anything."""
filename = str(file.filename or "").lower()
if not filename.endswith((".eml", ".msg")):
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
content = await file.read()
content = await file.read(10 * 1024 * 1024 + 1)
if not content or len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
from app.services.email_service import EmailService
@ -260,6 +262,13 @@ async def analyze_email_for_new_contact(file: UploadFile = File(...)):
empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")}
suggestions = _contact_suggestions_from_email(empty_contact, parsed)
contact = {item["field"]: item["suggested"] for item in suggestions}
if use_ai:
from app.services.contact_mail_ai import extract_contact
try:
ai_contact = await extract_contact(parsed)
contact.update(ai_contact)
except Exception:
raise HTTPException(status_code=502, detail="AI kunne ikke analysere mailen. Kontrollér AI-indstillingerne og prøv igen.")
contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
company = _company_from_email_body(_plain_email_body(parsed), contact_name)
company["existing_id"] = None
@ -680,6 +689,9 @@ def _contact_merge_counts(contact_id: int) -> list[dict]:
for label, table, column in CONTACT_MERGE_RELATIONS:
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
if (execute_query_single("SELECT to_regclass('contact_lifecycle_events') AS relation") or {}).get('relation'):
row = execute_query_single("SELECT COUNT(*)::int AS count FROM contact_lifecycle_events WHERE contact_id=%s", (contact_id,)) or {}
counts.append({"key": "contact_lifecycle_events", "label": "Onboarding/offboarding", "count": int(row.get('count') or 0)})
conversation_row = execute_query_single(
"""
SELECT COUNT(DISTINCT conversation.id)::int AS count
@ -810,6 +822,11 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest):
moved[table] = int(cursor.fetchone()["count"] or 0)
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
cursor.execute("SELECT to_regclass('contact_lifecycle_events') AS relation")
if cursor.fetchone()['relation']:
cursor.execute("UPDATE contact_lifecycle_events SET contact_id=%s,updated_at=NOW() WHERE contact_id=%s", (contact_id, source_id))
moved['contact_lifecycle_events'] = cursor.rowcount
cursor.execute(
"""
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
@ -831,6 +848,16 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest):
release_db_connection(conn)
@router.get("/contacts/{contact_id}/vendors")
async def get_contact_vendors(contact_id: int):
"""Supplier relations, kept separate from customer/company semantics."""
if not execute_query_single("SELECT id FROM contacts WHERE id=%s", (contact_id,)):
raise HTTPException(status_code=404, detail="Contact not found")
return execute_query("""SELECT v.id, v.name, vc.role, vc.is_primary
FROM vendor_contacts vc JOIN vendors v ON v.id=vc.vendor_id
WHERE vc.contact_id=%s ORDER BY vc.is_primary DESC, v.name, v.id""", (contact_id,)) or []
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies"""

View File

@ -165,6 +165,7 @@
</div>
</div>
<div id="contactVendorSummary" class="alert alert-warning d-none mb-3" aria-live="polite"></div>
<div id="contactEmailDropzone" class="contact-email-dropzone mb-3" role="button" tabindex="0" aria-label="Analysér Outlook-mail for kontaktopdateringer">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-envelope-arrow-down fs-4 text-primary"></i>
@ -313,6 +314,14 @@
</div>
</div>
<div class="col-12">
<div class="info-card" id="contactLifecycle" aria-labelledby="contactLifecycleHeading">
<h5 class="fw-bold mb-2" id="contactLifecycleHeading">Onboarding og offboarding</h5>
<p class="text-muted small mb-3">Planlæg start og fratrædelse, og få oprettet en sag i god tid.</p>
<div id="contactLifecycleFeedback" class="d-none" role="status" aria-live="polite"></div>
<div class="row g-3" id="contactLifecycleCards"><div class="col-12 text-muted">Henter forløb…</div></div>
</div>
</div>
<div class="col-12">
<div class="info-card">
<h5 class="fw-bold mb-3">System Info</h5>
@ -348,6 +357,10 @@
<div class="spinner-border text-primary"></div>
</div>
</div>
<div class="mb-4 d-none" id="contactVendorsSection">
<h5 class="fw-bold mt-4">Tilknyttede leverandører</h5>
<div class="row g-4" id="contactVendorsContainer"></div>
</div>
</div>
<!-- Contacts Tab -->
@ -873,6 +886,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">
@ -911,6 +942,7 @@
{% endblock %}
{% block extra_js %}
<script src="/static/js/contact-lifecycle.js?v=20261102-1"></script>
<!-- Alert Notes Components -->
<script src="/static/alert_notes/alert_check.js"></script>
{% include "alert_notes/frontend/alert_box.html" %}
@ -926,6 +958,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();
@ -1207,6 +1244,7 @@ function displayContact(contact) {
? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-secondary">Inaktiv</span>';
document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0;
loadContactVendors();
populateSessionCompanySelect(contact);
@ -1349,6 +1387,21 @@ async function loadCompanies() {
displayCompanies(contactData.companies);
}
async function loadContactVendors() {
const summary=document.getElementById('contactVendorSummary');
try {
const response=await fetch(`/api/v1/contacts/${contactId}/vendors`);
if(!response.ok)throw new Error('Leverandørkoblinger kunne ikke hentes');
const vendors=await response.json();
summary.classList.toggle('d-none',vendors.length===0);
summary.innerHTML=vendors.length ? `<strong><i class="bi bi-truck me-2"></i>Leverandørkontakt</strong><div class="mt-2">${vendors.map(v=>`<a class="badge bg-warning text-dark border me-2 mb-1 text-decoration-none" href="/vendors/${Number(v.id)}"><span class="fw-bold">Leverandør:</span> ${escapeHtml(v.name)}</a>`).join('')}</div>` : '';
document.getElementById('contactVendorsSection').classList.toggle('d-none',vendors.length===0);
document.getElementById('contactVendorsContainer').innerHTML=vendors.map(v=>`<div class="col-md-6"><div class="company-card border border-warning"><span class="badge bg-warning text-dark mb-2"><i class="bi bi-truck me-1"></i>Leverandør</span><h6 class="fw-bold"><a href="/vendors/${Number(v.id)}">${escapeHtml(v.name)}</a></h6>${v.is_primary?'<span class="badge bg-primary mb-2">Primær kontakt</span>':''}${v.role?`<div class="small"><span class="text-muted">Rolle:</span> ${escapeHtml(v.role)}</div>`:''}</div></div>`).join('');
} catch(error) {
summary.classList.remove('d-none');summary.textContent=error.message;
}
}
function displayCompanies(companies) {
const container = document.getElementById('companiesContainer');
@ -1773,7 +1826,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');
}
@ -1805,6 +1858,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>
@ -1813,6 +1870,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>
@ -2325,6 +2383,7 @@ async function loadRemoteSessions() {
}
function displayRemoteSessions(sessions) {
remoteSessionRows = sessions || [];
const container = document.getElementById('remoteSessionsContainer');
if (!container) return;
@ -2333,39 +2392,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() {

View File

@ -893,6 +893,7 @@ let selectedCompanyIds = new Set();
let currentContactsData = [];
let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null;
let pendingCreateVendorId = null;
let analyzedMailCompany = null;
let currentSort = {
key: 'name',
@ -917,6 +918,8 @@ document.addEventListener('DOMContentLoaded', () => {
const preselectedCustomerId = Number(urlParams.get('customer_id'));
const shouldOpenCreateModal = urlParams.get('create') === '1';
pendingCreateReturnTo = urlParams.get('return_to') || null;
const vendorId = Number(urlParams.get('vendor_id'));
pendingCreateVendorId = Number.isInteger(vendorId) && vendorId > 0 ? vendorId : null;
pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) && preselectedCustomerId > 0
? preselectedCustomerId
: null;
@ -1710,6 +1713,17 @@ async function createContact() {
const createdContactId = Number(newContact?.id) || null;
if (pendingCreateVendorId && createdContactId) {
const linkResponse = await fetch(`/api/v1/vendors/${pendingCreateVendorId}/contacts`, {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({contact_id:createdContactId,role:contactData.role || '',is_primary:contactData.is_primary})
}).catch(() => null);
if (!linkResponse || !linkResponse.ok) {
alert('Kontaktpersonen er oprettet, men kunne ikke tilknyttes leverandøren. Tilknyt den fra leverandørkortet.');
window.location.href = `/contacts/${createdContactId}`;
return;
}
}
if (pendingCreateReturnTo) {
window.location.href = pendingCreateReturnTo;
return;

View File

@ -59,12 +59,12 @@
<style>
.list-group-item-action { cursor: pointer; border-left: 3px solid transparent; }
.list-group-item-action:hover { background-color: #f8f9fa; }
.list-group-item-action.active { background-color: #e9ecef; color: #000; border-left-color: var(--bs-primary); border-color: rgba(0,0,0,0.125); }
.list-group-item-action:hover { background-color: var(--theme-surface-soft, #f8f9fa); }
.list-group-item-action.active { background-color: var(--theme-surface-soft, #e9ecef); color: var(--theme-text, #000); border-left-color: var(--bs-primary); border-color: rgba(0,0,0,0.125); }
.timestamp-link { cursor: pointer; color: var(--bs-primary); text-decoration: none; font-weight: 500;}
.timestamp-link:hover { text-decoration: underline; }
.transcript-line { transition: background-color 0.2s; border-radius: 4px; padding: 2px 4px; }
.transcript-line:hover { background-color: #fff3cd; }
.transcript-line:hover { background-color: var(--theme-warning-bg, #fff3cd); }
</style>
<script>

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

@ -37,6 +37,10 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Local-network/VPN remote control. Configure a separate random key before enabling.
REMOTE_ENABLED: bool = False
REMOTE_LOOKUP_KEY: str = ""
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
@ -123,6 +127,14 @@ class Settings(BaseSettings):
# Ollama LLM
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2"
# Shared workflows are opt-in. Existing billing remains authoritative by default.
WORKFLOWS_ENABLED: bool = False
WORKFLOWS_MODE: str = "shadow"
WORKFLOWS_CUSTOMER_IDS: List[int] = []
WORKFLOWS_INTERNAL_ENABLED: bool = False
WORKFLOW_AI_AUTO_THRESHOLD: float = 90
WORKFLOW_AI_SUGGEST_THRESHOLD: float = 70
WORKFLOW_AI_ENABLED: bool = False
# Primary company CVR used to prevent AI invoice extraction from treating
# the buyer (BMC) as the supplier.
OWN_CVR: str = "29522790"

View File

@ -5,17 +5,38 @@ PostgreSQL connection and helpers using psycopg2
import psycopg2
from psycopg2.extras import RealDictCursor
from psycopg2.pool import SimpleConnectionPool
from psycopg2.pool import ThreadedConnectionPool
from functools import lru_cache
from typing import Optional
import logging
from contextlib import contextmanager
from contextvars import ContextVar
from app.core.config import settings
logger = logging.getLogger(__name__)
# Connection pool
connection_pool: Optional[SimpleConnectionPool] = None
connection_pool: Optional[ThreadedConnectionPool] = None
_transaction_connection = ContextVar("bmc_transaction_connection", default=None)
@contextmanager
def atomic_transaction():
"""Opt-in transaction shared by existing helpers; legacy calls are unchanged."""
if _transaction_connection.get() is not None:
raise RuntimeError("Nested atomic transactions are not supported")
conn = get_db_connection()
token = _transaction_connection.set(conn)
try:
yield conn
conn.commit()
except BaseException:
conn.rollback()
raise
finally:
_transaction_connection.reset(token)
release_db_connection(conn)
def init_db():
@ -23,7 +44,8 @@ def init_db():
global connection_pool
try:
connection_pool = SimpleConnectionPool(
# Synchronous API handlers run in worker threads, including remote polls.
connection_pool = ThreadedConnectionPool(
minconn=1,
maxconn=10,
dsn=settings.DATABASE_URL
@ -63,7 +85,8 @@ def get_db():
def execute_query(query: str, params: tuple = None, fetch: bool = True):
"""Execute a SQL query and return results"""
conn = get_db_connection()
owned = _transaction_connection.get() is None
conn = _transaction_connection.get() or get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params)
@ -73,7 +96,7 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper()
is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT'))
if is_write:
if is_write and owned:
conn.commit()
# Only fetch if there are results to fetch (cursor.description is not None)
@ -82,47 +105,57 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
return cursor.rowcount
except Exception as e:
conn.rollback()
if owned:
conn.rollback()
logger.error(f"Query error: {e}")
raise
finally:
release_db_connection(conn)
if owned:
release_db_connection(conn)
def execute_insert(query: str, params: tuple = None):
"""Execute INSERT query and return new ID (requires RETURNING clause)"""
conn = get_db_connection()
owned = _transaction_connection.get() is None
conn = _transaction_connection.get() or get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params)
conn.commit()
if owned:
conn.commit()
result = cursor.fetchone()
if result:
# Return first column value regardless of name (id, extraction_id, file_id, etc.)
return result[list(result.keys())[0]] if result else None
return None
except Exception as e:
conn.rollback()
if owned:
conn.rollback()
logger.error(f"Insert error: {e}")
raise
finally:
release_db_connection(conn)
if owned:
release_db_connection(conn)
def execute_update(query: str, params: tuple = None):
"""Execute UPDATE/DELETE query and return affected rows"""
conn = get_db_connection()
owned = _transaction_connection.get() is None
conn = _transaction_connection.get() or get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params)
conn.commit()
if owned:
conn.commit()
return cursor.rowcount
except Exception as e:
conn.rollback()
if owned:
conn.rollback()
logger.error(f"Update error: {e}")
raise
finally:
release_db_connection(conn)
if owned:
release_db_connection(conn)
def execute_query_single(query: str, params: tuple = None):

View File

@ -0,0 +1,309 @@
"""Local, atomic customer consolidation; never writes to external systems.
Relations are discovered from the actual schema, including legacy integer Hub
references without foreign keys. Conflicts abort the entire operation. Preview
executes the same transaction and rolls it back, including trigger effects.
"""
import hashlib
import json
import logging
from contextlib import contextmanager
import psycopg2
from psycopg2 import sql
from psycopg2.extras import RealDictCursor, Json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.core.database import get_db_connection, release_db_connection
from app.core.auth_dependencies import require_permission
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class MergeSelection(BaseModel):
target_id: int = Field(gt=0)
source_ids: list[int] = Field(min_length=1, max_length=50)
class MergeConfirmation(MergeSelection):
preview_token: str = Field(min_length=64, max_length=64)
@contextmanager
def transaction(commit=False):
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SET LOCAL lock_timeout = '5s'")
cursor.execute("SET LOCAL statement_timeout = '30s'")
yield cursor
if commit:
conn.commit()
else:
conn.rollback()
except BaseException:
conn.rollback()
raise
finally:
release_db_connection(conn)
def selections(target, sources):
sources = sorted(set(sources))
if not sources or target in sources or any(i <= 0 for i in sources):
raise HTTPException(422, 'Vælg en hovedkunde og mindst én anden kunde.')
return sources
def relations(cur):
"""Only real tables in current schema; never confuse tmodule customer IDs.
A numeric customer reference with an FK to another entity is excluded.
Partition children are handled through their parent, avoiding double moves.
"""
cur.execute("""
SELECT c.relname AS table_name, a.attname AS column_name,
EXISTS(SELECT 1 FROM pg_attribute x WHERE x.attrelid=c.oid
AND x.attname='entity_type' AND NOT x.attisdropped) AS polymorphic,
EXISTS(SELECT 1 FROM pg_constraint f WHERE f.conrelid=c.oid AND f.contype='f'
AND a.attnum=ANY(f.conkey) AND f.confrelid='customers'::regclass) AS customer_fk,
EXISTS(SELECT 1 FROM pg_constraint f WHERE f.conrelid=c.oid AND f.contype='f'
AND a.attnum=ANY(f.conkey) AND f.confrelid<>'customers'::regclass) AS other_fk
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum>0 AND NOT a.attisdropped
WHERE n.nspname=current_schema() AND c.relkind IN ('r','p') AND NOT c.relispartition
AND c.relname<>'customer_merge_history'
AND a.atttypid IN ('int2'::regtype,'int4'::regtype,'int8'::regtype)
AND (a.attname='customer_id' OR a.attname LIKE '%%_customer_id'
OR a.attname='entity_id' OR EXISTS(
SELECT 1 FROM pg_constraint f WHERE f.conrelid=c.oid AND f.contype='f'
AND a.attnum=ANY(f.conkey) AND f.confrelid='customers'::regclass))
ORDER BY c.relname,a.attname
""")
return [r for r in cur.fetchall() if not r['other_fk']
and r['column_name'] != 'merged_into_customer_id'
and (r['column_name'] != 'entity_id' or r['polymorphic'])
# These logs use IDs from the time module, not Hub IDs.
and not (r['table_name'] in ('tmodule_sync_log', 'tticket_audit_log')
and r['column_name'] == 'entity_id')]
def where(ref):
clause = sql.SQL('{} = ANY(%s)').format(sql.Identifier(ref['column_name']))
if ref['column_name'] == 'entity_id':
clause += sql.SQL(" AND entity_type='customer'")
return clause
def signature(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
def mentions_customer(value, sources):
if isinstance(value,list):
return any(mentions_customer(v,sources) for v in value)
if not isinstance(value,dict):
return False
candidates=[value.get('customer_id')]
if value.get('field') == 'customer_id':
candidates.extend(value.get('value') if isinstance(value.get('value'),list) else [value.get('value')])
return any(str(v) in {str(i) for i in sources} for v in candidates if v is not None) or any(
mentions_customer(v,sources) for v in value.values() if isinstance(v,(dict,list)))
IDENTITY_FIELDS = ('economic_customer_number', 'vtiger_id', 'vtiger_account_no', 'cvr_number')
IDENTITY_LABELS = {'economic_customer_number':'e-conomic-kundenumre','vtiger_id':'vTiger-id’er',
'vtiger_account_no':'vTiger-kundenumre','cvr_number':'CVR-numre'}
FILL_FIELDS = ('email', 'phone', 'address', 'email_domain', 'city', 'postal_code',
'country', 'website', 'invoice_email', 'mobile_phone', 'wiki_slug')
def field_plan(customers, target):
changes, differences = {}, []
for field in IDENTITY_FIELDS + FILL_FIELDS:
values = {str(c[field]).strip(): c[field] for c in customers if c.get(field) not in (None, '')}
if field in IDENTITY_FIELDS and len(values) > 1:
raise HTTPException(409, f'Kunderne har forskellige {IDENTITY_LABELS[field]}. Afklar relationen før sammenlægning.')
if not target.get(field) and len(values) == 1:
changes[field] = next(iter(values.values()))
elif len(values) > 1:
differences.append(field)
excluded=set(IDENTITY_FIELDS+FILL_FIELDS) | {'id','created_at','updated_at','deleted_at','last_synced_at',
'merged_into_customer_id','parent_customer_id','billing_customer_id'}
for field in sorted(set().union(*(c.keys() for c in customers))-excluded):
values={json.dumps(c.get(field),default=str,sort_keys=True) for c in customers}
if len(values)>1: differences.append(field)
return changes, differences
def prepare(cur, target_id, source_ids):
cur.execute("SELECT to_regclass('customer_merge_history') AS history")
if not cur.fetchone()['history']:
raise HTTPException(503, 'Kundesammenlægning kræver migration 1074_customer_merge.sql.')
# Consistent locking order for merges; FK inserts are serialized with these locks.
cur.execute('SELECT * FROM customers WHERE id=ANY(%s) ORDER BY id FOR UPDATE',
(sorted([target_id] + source_ids),))
customers = [dict(r) for r in cur.fetchall()]
if len(customers) != len(source_ids) + 1:
raise HTTPException(404, 'En valgt kunde findes ikke længere.')
if any(c.get('merged_into_customer_id') or c.get('deleted_at') or c.get('is_active') is False for c in customers):
raise HTTPException(409, 'En valgt kunde er allerede samlet, slettet eller deaktiveret. Genindlæs listen.')
target = next(c for c in customers if c['id'] == target_id)
changes, differences = field_plan(customers, target)
refs = relations(cur)
# Lock relation tables so legacy references without FKs cannot change mid-merge.
tables = {r['table_name'] for r in refs} | {'customers'}
for table in ('bmc_workflows','bmc_workflow_versions','bmc_workflow_events','bmc_workflow_runtime'):
cur.execute('SELECT to_regclass(%s) AS name',(table,))
if cur.fetchone()['name']: tables.add(table)
for table in sorted(tables):
cur.execute(sql.SQL('LOCK TABLE {} IN SHARE ROW EXCLUSIVE MODE').format(sql.Identifier(table)))
state, movements, origins = [], [], []
for ref in refs:
cur.execute(sql.SQL('SELECT * FROM {} WHERE {}').format(sql.Identifier(ref['table_name']),where(ref)),
([target_id]+source_ids,))
rows = [dict(r) for r in cur.fetchall()]
if ref['table_name']=='ordre_drafts':
economic=changes.get('economic_customer_number',target.get('economic_customer_number'))
if any(r.get('exported_economic_customer_number') not in (None,'') and
str(r['exported_economic_customer_number']).strip()!=str(economic).strip() for r in rows):
raise HTTPException(409,'En ordre eller fakturakladde er eksporteret til et andet e-conomic-kundenummer. Afklar fakturarelationen før sammenlægning.')
if rows:
state.append({'table':ref['table_name'],'column':ref['column_name'],
'rows':sorted(rows,key=lambda r:json.dumps(r,sort_keys=True,default=str))})
cur.execute('''SELECT a.attname FROM pg_index i JOIN pg_attribute a
ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
WHERE i.indrelid=%s::regclass AND i.indisprimary ORDER BY a.attname''',
(ref['table_name'],))
keys=[r['attname'] for r in cur.fetchall()]
if not keys:
raise HTTPException(409,f"Tabellen {ref['table_name']} mangler en entydig nøgle til flyttehistorikken.")
origins.extend({'table':ref['table_name'],'column':ref['column_name'],
'key':{k:r[k] for k in keys},'original_customer_id':r[ref['column_name']]}
for r in rows if r[ref['column_name']] in source_ids)
if ref['table_name'] in ('contact_companies','entity_tags'):
origins.append({'table':ref['table_name'],'original_memberships':rows})
count = sum(r[ref['column_name']] in source_ids for r in rows)
if count:
movements.append(dict(ref, count=count))
# bmc_workflows stores Hub IDs as text, so numeric FK discovery cannot see it.
cur.execute("SELECT to_regclass('bmc_workflows') AS name")
if cur.fetchone()['name']:
cur.execute('SELECT * FROM bmc_workflows WHERE customer_id=ANY(%s) ORDER BY id',
([str(i) for i in [target_id]+source_ids],))
rows=[dict(r) for r in cur.fetchall()]
state.append({'table':'bmc_workflows','rows':rows})
origins.extend({'table':'bmc_workflows','column':'customer_id','key':{'id':r['id']},
'original_customer_id':r['customer_id']} for r in rows
if r['customer_id'] in [str(i) for i in source_ids])
count=sum(r['customer_id'] in [str(i) for i in source_ids] for r in rows)
if count: movements.append(dict(table_name='bmc_workflows',column_name='customer_id',count=count))
cur.execute("SELECT to_regclass('bmc_workflow_runtime') AS name")
if cur.fetchone()['name']:
cur.execute('SELECT customer_ids FROM bmc_workflow_runtime')
if any(set(r['customer_ids'] or []) & set(source_ids) for r in cur.fetchall()):
raise HTTPException(409, 'En kildekunde er valgt i workflowmotorens kundeliste. Afklar aktiveringen før sammenlægning.')
if set(getattr(settings,'WORKFLOWS_CUSTOMER_IDS',[]) or []) & set(source_ids):
raise HTTPException(409,'En kildekunde er valgt i workflowkonfigurationen. Afklar aktiveringen før sammenlægning.')
for table in ('bmc_workflow_events','bmc_workflow_versions'):
cur.execute('SELECT to_regclass(%s) AS name',(table,))
if not cur.fetchone()['name']: continue
if table == 'bmc_workflow_events':
cur.execute("SELECT context,entity_type,entity_id FROM bmc_workflow_events WHERE status IN ('pending','processing','failed')")
blocked=any(mentions_customer(r['context'],source_ids) or
(r['entity_type']=='customer' and str(r['entity_id']) in {str(i) for i in source_ids}) for r in cur.fetchall())
else:
cur.execute('''SELECT v.definition FROM bmc_workflow_versions v JOIN bmc_workflows w
ON w.active_version_id=v.id WHERE w.enabled''')
blocked=any(mentions_customer(r['definition'],source_ids) for r in cur.fetchall())
if blocked:
raise HTTPException(409,'En kildekunde bruges i aktive workflowregler eller uafsluttede job. Afklar dem før sammenlægning.')
token=signature({'customers':customers,'state':state,'target':target_id,'sources':source_ids})
return customers, changes, differences, movements, token, origins
def move(cur, target, sources, customers, changes, movements):
"""Any unhandled unique constraint is a blocking conflict, never data loss."""
archived=[]
# Transfer unique external IDs only after saving original customer snapshots.
for field in IDENTITY_FIELDS:
if field in customers[0] and (field != 'cvr_number' or field in changes):
cur.execute(sql.SQL('UPDATE customers SET {}=NULL WHERE id=ANY(%s)').format(sql.Identifier(field)),(sources,))
for field,value in changes.items():
cur.execute(sql.SQL('UPDATE customers SET {}=%s WHERE id=%s').format(sql.Identifier(field)),(value,target))
if any(r['table_name']=='customer_relationships' for r in movements):
cur.execute('''DELETE FROM customer_relationships WHERE parent_customer_id=ANY(%s)
AND child_customer_id=ANY(%s) RETURNING *''',([target]+sources,[target]+sources))
archived.extend({'table':'customer_relationships','row':dict(r)} for r in cur.fetchall())
# Shared tag/contact memberships are consolidated; retain their complete old rows in audit.
for table,key,condition in [('contact_companies','contact_id',''),('entity_tags','tag_id'," AND entity_type='customer'")]:
if not any(r['table_name']==table for r in movements): continue
column='entity_id' if table=='entity_tags' else 'customer_id'
cur.execute(sql.SQL('SELECT * FROM {} WHERE {}=ANY(%s)'+condition+' ORDER BY {}').format(
sql.Identifier(table),sql.Identifier(column),sql.Identifier('id')),([target]+sources,))
rows=[dict(r) for r in cur.fetchall()];seen={}
for row in sorted(rows,key=lambda r:(r[column]!=target,r['id'])):
if row[key] not in seen:
seen[row[key]]=row;continue
kept=seen[row[key]]
archived.append({'table':table,'row':row})
if table=='contact_companies':
notes='\n'.join(dict.fromkeys(str(v) for v in (kept.get('notes'),row.get('notes')) if v))
roles=' / '.join(dict.fromkeys(str(v) for v in (kept.get('role'),row.get('role')) if v))
cur.execute('UPDATE contact_companies SET notes=%s,role=%s,is_primary=%s WHERE id=%s',
(notes or None,roles or None,bool(kept.get('is_primary') or row.get('is_primary')),kept['id']))
kept.update(notes=notes,role=roles,is_primary=bool(kept.get('is_primary') or row.get('is_primary')))
cur.execute(sql.SQL('DELETE FROM {} WHERE id=%s').format(sql.Identifier(table)),(row['id'],))
if table=='contact_companies' and any(r[column]==target and r.get('is_primary') for r in rows):
cur.execute('UPDATE contact_companies SET is_primary=FALSE WHERE customer_id=ANY(%s)',(sources,))
for ref in movements:
table,column=ref['table_name'],ref['column_name']
# Avoid introducing self-referential parent/billing relations on merged rows.
if table=='customers':
cur.execute(sql.SQL('UPDATE customers SET {}=NULL WHERE id=ANY(%s) AND {}=ANY(%s)').format(
sql.Identifier(column),sql.Identifier(column)),([target]+sources,sources))
value=str(target) if table=='bmc_workflows' else target
ids=[str(i) for i in sources] if table=='bmc_workflows' else sources
cur.execute(sql.SQL('UPDATE {} SET {}=%s WHERE {}').format(sql.Identifier(table),sql.Identifier(column),where(ref)),(value,ids))
cur.execute('UPDATE customers SET is_active=FALSE,merged_into_customer_id=%s,updated_at=now() WHERE id=ANY(%s)',(target,sources))
return archived
def run(selection, actor_id, preview_token=None):
sources=selections(selection.target_id,selection.source_ids)
try:
with transaction(commit=preview_token is not None) as cur:
customers,changes,differences,movements,token,origins=prepare(cur,selection.target_id,sources)
if preview_token is not None and preview_token != token:
raise HTTPException(409,'Data er ændret siden forhåndsvisningen. Vis sammenlægningen igen.')
archived=move(cur,selection.target_id,sources,customers,changes,movements)
if preview_token is not None:
cur.execute('''INSERT INTO customer_merge_history(target_customer_id,source_customer_ids,actor_id,snapshot,movements)
VALUES(%s,%s,%s,%s,%s) RETURNING id''',
(selection.target_id,sources,actor_id,Json({'customers':customers,'relation_origins':origins,'archived_memberships':archived},dumps=lambda v:json.dumps(v,default=str)),Json(movements)))
history_id=cur.fetchone()['id']
else: history_id=None
return {'target_id':selection.target_id,'source_ids':sources,'preview_token':token,
'customers':[{'id':c['id'],'name':c['name']} for c in customers],
'relations':[{'table':r['table_name'],'column':r['column_name'],'count':r['count']} for r in movements],
'filled_fields':list(changes),'kept_main_fields':differences,'history_id':history_id,
'external_systems_changed':False}
except psycopg2.IntegrityError as exc:
table=exc.diag.table_name or 'en relateret tabel'
raise HTTPException(409,f'Sammenlægningen har en konflikt i {table}. Ingen data er flyttet; afklar de overlappende relationer først.') from exc
except psycopg2.Error as exc:
logger.warning('Customer merge rolled back (%s)',exc.pgcode)
raise HTTPException(409,'Sammenlægningen kunne ikke gennemføres. Ingen data er flyttet. Genindlæs og prøv igen.') from exc
@router.post('/customers/merge/preview')
def preview(selection:MergeSelection,actor=Depends(require_permission('customers.edit'))):
return run(selection,actor['id'])
@router.post('/customers/merge')
def merge(selection:MergeConfirmation,actor=Depends(require_permission('customers.edit'))):
return run(selection,actor['id'],selection.preview_token)

View File

@ -6,7 +6,7 @@ Adapted from OmniSync for BMC Hub
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from pydantic import BaseModel, Field
import logging
import asyncio
import aiohttp
@ -23,6 +23,10 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# Static merge routes precede the legacy /customers/{customer_id} routes.
from app.customers.backend.merge import router as customer_merge_router
router.include_router(customer_merge_router)
def _ensure_customer_supplier_tag(customer_id: int) -> None:
"""Ensure linked customers are tagged as suppliers."""
@ -113,6 +117,8 @@ class CustomerBase(BaseModel):
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
supplier_service_enrolled: Optional[bool] = False
invoice_fee_exempt: bool = False
invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False)
class CustomerCreate(CustomerBase):
@ -136,6 +142,8 @@ class CustomerUpdate(BaseModel):
mobile_phone: Optional[str] = None
department: Optional[str] = None
supplier_service_enrolled: Optional[bool] = None
invoice_fee_exempt: Optional[bool] = None
invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False)
class EmailDomainCreate(BaseModel):
@ -906,8 +914,8 @@ async def create_customer(customer: CustomerCreate):
customer_id = execute_insert(
"""INSERT INTO customers
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
country, website, is_active, invoice_email, mobile_phone, supplier_service_enrolled)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
country, website, is_active, invoice_email, mobile_phone, supplier_service_enrolled, invoice_fee_exempt, invoice_fee_amount)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
customer.name,
@ -923,7 +931,9 @@ async def create_customer(customer: CustomerCreate):
customer.is_active,
customer.invoice_email,
customer.mobile_phone,
customer.supplier_service_enrolled
customer.supplier_service_enrolled,
customer.invoice_fee_exempt,
customer.invoice_fee_amount
)
)
@ -958,6 +968,8 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
params = []
update_dict = update.dict(exclude_unset=True)
if update_dict.get("invoice_fee_exempt", False) is None:
raise HTTPException(status_code=422, detail="Fritagelse skal være ja eller nej")
fields_changed = list(update_dict.keys())
for field, value in update_dict.items():

View File

@ -3,6 +3,7 @@
{% block title %}Kunde Detaljer - BMC Hub{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/static/css/company-contacts.css?v=20261005-2">
<style>
.customer-header {
background: var(--accent);
@ -56,6 +57,26 @@
margin-right: 0.75rem;
}
/* Keep customer tabs isolated even when an external Bootstrap stylesheet is unavailable. */
#customerTabContent > .tab-pane {
display: none;
}
#customerTabContent > .tab-pane.active {
display: block;
opacity: 1;
}
#customerTabLayout {
overflow-anchor: none;
}
#customerTabNav .nav-link {
display: flex;
align-items: center;
min-height: 48px;
}
.info-card {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
@ -162,12 +183,12 @@
.internet-status-active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
color: var(--theme-success-text, #146c43);
}
.internet-status-inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
color: var(--theme-text, #495057);
}
.internet-status-pending, .internet-status-planned {
@ -437,7 +458,7 @@
.customer-invoice-note-panel {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
background: #fff;
background: var(--theme-surface, #fff);
padding: 0.8rem 0.95rem;
margin-bottom: 1rem;
font-size: 0.9rem;
@ -465,7 +486,7 @@
}
.matrix-cell-button:hover {
color: #0b3b5a;
color: var(--theme-primary-text, #0b3b5a);
}
.invoice-detail-summary {
@ -511,7 +532,7 @@
position: sticky;
top: 0;
z-index: 10;
background: white;
background: var(--theme-surface, white);
padding: 1rem;
margin: -1rem -1rem 1rem -1rem;
border-radius: 8px 8px 0 0;
@ -597,107 +618,6 @@
transform: rotate(-15deg) scale(1.1);
}
.contacts-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.08);
}
.contacts-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.65rem;
margin-bottom: 0.9rem;
padding: 0.78rem 0.9rem;
background: linear-gradient(135deg, rgba(15, 76, 117, 0.08) 0%, rgba(15, 76, 117, 0.02) 100%);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
}
.contacts-search {
max-width: 460px;
min-width: 280px;
}
.contacts-search .form-control,
.contacts-search .input-group-text {
border-color: rgba(15, 76, 117, 0.2);
}
.contacts-table {
margin-bottom: 0;
}
.contacts-table thead th {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(15, 76, 117, 0.06);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
padding: 0.82rem 0.8rem;
}
.contacts-table tbody td {
border-color: rgba(0, 0, 0, 0.06);
padding: 0.85rem 0.8rem;
}
.contacts-table .contact-name {
font-weight: 600;
color: var(--text-primary);
}
.contacts-table .contact-name-link {
color: var(--text-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease;
}
.contacts-table .contact-name-link:hover {
color: var(--accent);
border-color: rgba(15, 76, 117, 0.35);
}
.contacts-table .contact-email a {
overflow-wrap: anywhere;
}
.contacts-table .contact-number {
white-space: nowrap;
}
.contacts-table .contact-phone-wrap,
.contacts-table .contact-mobile-wrap {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.contacts-table .btn-voip {
border-color: rgba(25, 135, 84, 0.6);
color: #198754;
}
.contacts-table .contact-phone-wrap .btn,
.contacts-table .contact-mobile-wrap .btn {
padding: 0.18rem 0.52rem;
line-height: 1.2;
}
.contacts-table .primary-pill {
font-size: 0.72rem;
font-weight: 700;
}
.consistency-contact-comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -765,11 +685,11 @@
}
.consistency-diff-old {
color: #8b1e3f;
color: var(--theme-danger-text, #8b1e3f);
}
.consistency-diff-new {
color: #146c43;
color: var(--theme-success-text, #146c43);
}
@media (max-width: 992px) {
@ -832,7 +752,7 @@
<div id="alert-notes-container"></div>
<!-- Bankruptcy Alert -->
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: var(--theme-danger-bg, #ffeaea); color: var(--theme-danger-text, #842029);">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
@ -865,10 +785,10 @@
</div>
<!-- Content Layout with Sidebar Navigation -->
<div class="row">
<div class="row" id="customerTabLayout">
<div class="col-lg-3 col-md-4">
<!-- Vertical Navigation -->
<ul class="nav nav-pills nav-pills-vertical flex-column" role="tablist">
<ul class="nav nav-pills nav-pills-vertical flex-column" id="customerTabNav" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#overview">
<i class="bi bi-info-circle"></i>Oversigt
@ -974,7 +894,7 @@
<div class="col-lg-9 col-md-8">
<!-- Tab Content -->
<div class="tab-content">
<div class="tab-content" id="customerTabContent">
<!-- Overview Tab -->
<div class="tab-pane fade show active" id="overview">
<div class="row g-4">
@ -1057,6 +977,19 @@
<span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span>
</div>
<div class="info-row">
<label class="info-label" for="invoiceFeeExempt">Fritaget for faktureringsgebyr</label>
<input type="checkbox" class="form-check-input" id="invoiceFeeExempt">
</div>
<div class="info-row">
<label class="info-label" for="invoiceFeeAmount">Særligt gebyrbeløb</label>
<div style="max-width:220px">
<input id="invoiceFeeAmount" type="number" min="0" step="0.01" class="form-control form-control-sm" placeholder="Standardvarens pris">
<small class="text-muted">Tomt = standardpris. 0 = intet gebyr. Leverandørservice fritager også.</small>
<button type="button" id="invoiceFeeSave" class="btn btn-sm btn-outline-primary mt-2" onclick="saveCustomerInvoiceFee()">Gem gebyrindstillinger</button>
<div id="invoiceFeeStatus" class="small mt-1" role="status"></div>
</div>
</div>
<div class="info-row">
<label class="info-label form-check-label" for="supplierServiceEnrolled">Benytter leverandørservice</label>
<div class="text-end">
@ -1067,6 +1000,32 @@
</div>
</div>
<div class="col-12" id="customerWorkflowCard" data-customer-id="{{ customer_id }}" hidden>
<section class="info-card" aria-labelledby="customerWorkflowHeading">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<h5 id="customerWorkflowHeading" class="fw-bold mb-0">Workflows</h5>
<span id="customerWorkflowEffective" class="badge text-bg-secondary">Henter status…</span>
</div>
<div class="d-flex align-items-start justify-content-between flex-wrap gap-3">
<div class="flex-grow-1">
<div class="form-check form-switch mb-1">
<input id="customerWorkflowEnabled" class="form-check-input" type="checkbox" role="switch" disabled>
<label class="form-check-label" for="customerWorkflowEnabled">Brug workflows for kunden</label>
</div>
<div id="customerWorkflowGlobal" class="small text-muted" aria-live="polite"></div>
<div id="customerWorkflowPreference" class="small text-muted"></div>
</div>
<div id="customerWorkflowWrite" class="flex-wrap align-items-end gap-2" hidden>
<label class="small mb-0" for="customerWorkflowReason">Begrundelse
<input id="customerWorkflowReason" class="form-control form-control-sm" placeholder="Hvorfor ændres indstillingen?" minlength="3" maxlength="1000" style="min-width:220px" disabled>
</label>
<button id="customerWorkflowSave" type="button" class="btn btn-sm btn-outline-primary" disabled>Gem</button>
</div>
</div>
<div id="customerWorkflowMessage" class="small mt-2" role="status"></div>
</section>
</div>
<div class="col-12">
<div class="info-card">
<h5 class="fw-bold mb-3">Integration</h5>
@ -1162,49 +1121,9 @@
<!-- Contacts Tab -->
<div class="tab-pane fade" id="contacts">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
<small class="text-muted">Direkte kontaktoplysninger for denne kunde</small>
</div>
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
</button>
</div>
<div class="contacts-toolbar">
<div class="input-group contacts-search">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="contactsSearchInput" placeholder="Søg i navn, titel, email eller nummer">
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsOnlyCallableToggle">Kun med nummer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsClearFilters">Nulstil</button>
<span class="badge text-bg-light border" id="contactsResultCount">0</span>
</div>
</div>
<div class="contacts-panel">
<div class="table-responsive" id="contactsContainer">
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{% with contact_owner_label='kunde', contact_add_handler='showAddContactModal' %}
{% include "shared/frontend/company_contacts_panel.html" %}
{% endwith %}
</div>
<!-- Cases Tab -->
@ -1728,9 +1647,9 @@
<small class="text-muted">Hardware knyttet til kunden</small>
</div>
<div class="d-flex align-items-center gap-2">
<a class="btn btn-sm btn-primary" href="/hardware/new">
<button type="button" class="btn btn-sm btn-primary" onclick="openCustomerAssetEditor('hardware')">
<i class="bi bi-plus-lg me-2"></i>Tilføj hardware
</a>
</button>
<label for="hardwareGroupBy" class="form-label mb-0 small text-muted">Gruppér efter</label>
<select class="form-select form-select-sm" id="hardwareGroupBy" style="min-width: 180px;">
<option value="location">Lokation</option>
@ -1773,6 +1692,7 @@
<h5 class="fw-bold mb-0">Links / Endpoints</h5>
<small class="text-muted">Driftslinks knyttet til denne kunde</small>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="openCustomerAssetEditor('link')"><i class="bi bi-plus-lg me-2"></i>Opret link</button>
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn fuld visning
</a>
@ -2347,6 +2267,10 @@
{% endblock %}
{% block extra_js %}
<script src="/static/js/link-targets.js?v=20261102-1"></script>
<script src="/static/js/customer-workflow.js?v=20261008-1"></script>
<script src="/static/js/contact-mail-import.js?v=20261005-1"></script>
<script src="/static/js/company-contacts.js?v=20261005-2"></script>
<!-- Alert Notes Components -->
<script src="/static/alert_notes/alert_check.js"></script>
{% include "alert_notes/frontend/alert_box.html" %}
@ -2371,6 +2295,64 @@ const customerEmailsLimit = 25;
let eventListenersAdded = false;
function customerTabFromHash(hash = window.location.hash) {
if (!/^#[a-z0-9-]+$/i.test(hash || '')) return null;
return document.querySelector(`#customerTabNav a[data-bs-toggle="tab"][href="${hash}"]`);
}
function showCustomerTab(tab) {
const targetId = tab?.getAttribute('href')?.slice(1);
const target = targetId && document.getElementById(targetId);
const content = document.getElementById('customerTabContent');
if (!target || target.parentElement !== content) return false;
document.querySelectorAll('#customerTabNav a[data-bs-toggle="tab"]').forEach((link) => {
link.classList.toggle('active', link === tab);
link.setAttribute('aria-selected', String(link === tab));
});
document.querySelectorAll('#customerTabContent > .tab-pane').forEach((pane) => {
const active = pane === target;
pane.classList.toggle('active', active);
pane.classList.toggle('show', active);
});
return true;
}
function syncCustomerTabHash(tab) {
const hash = tab?.getAttribute('href');
if (hash && window.location.hash !== hash) {
history.replaceState(null, '', `${window.location.pathname}${window.location.search}${hash}`);
}
}
function activateCustomerTab(tab, { updateHash = true } = {}) {
const alreadyActive = tab.classList.contains('active');
if (!showCustomerTab(tab)) return;
if (updateHash) syncCustomerTabHash(tab);
if (!alreadyActive) tab.dispatchEvent(new Event('shown.bs.tab'));
}
function initialiseCustomerTabs() {
const nav = document.getElementById('customerTabNav');
if (!nav || nav.dataset.initialised === 'true') return;
nav.dataset.initialised = 'true';
nav.addEventListener('click', (event) => {
const tab = event.target.closest('a[data-bs-toggle="tab"]');
if (!tab || !nav.contains(tab)) return;
event.preventDefault();
event.stopImmediatePropagation();
activateCustomerTab(tab);
}, true);
const openHashTab = () => {
const tab = customerTabFromHash();
if (!tab) return;
activateCustomerTab(tab, { updateHash: false });
};
openHashTab();
window.addEventListener('hashchange', openHashTab);
}
function getAuthHeaders() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {};
@ -2510,12 +2492,7 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
if (window.location.hash) {
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
if (hashTab && window.bootstrap?.Tab) {
bootstrap.Tab.getOrCreateInstance(hashTab).show();
}
}
initialiseCustomerTabs();
eventListenersAdded = true;
});
@ -2812,6 +2789,8 @@ function displayCustomer(customer) {
document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>';
document.getElementById('invoiceFeeExempt').checked = customer.invoice_fee_exempt === true;
document.getElementById('invoiceFeeAmount').value = customer.invoice_fee_amount ?? '';
document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true;
// Integration
@ -2828,6 +2807,28 @@ function displayCustomer(customer) {
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container');
}
async function saveCustomerInvoiceFee() {
const amount = document.getElementById('invoiceFeeAmount');
if (!amount.reportValidity()) return;
const button = document.getElementById('invoiceFeeSave');
const status = document.getElementById('invoiceFeeStatus');
button.disabled = true;
status.textContent = 'Gemmer…';
try {
const payload = {invoice_fee_exempt: document.getElementById('invoiceFeeExempt').checked,
invoice_fee_amount: amount.value === '' ? null : Number(amount.value)};
const response = await fetch(`/api/v1/customers/${customerId}`, {
method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
if (!response.ok) throw new Error((await response.json()).detail || 'Kunne ikke gemme gebyrindstillinger');
Object.assign(customerData, payload);
status.className = 'small text-success mt-1';
status.textContent = 'Gemt. Gælder nye ordrer.';
} catch (error) {
status.className = 'small text-danger mt-1'; status.textContent = error.message;
} finally { button.disabled = false; }
}
async function saveSupplierServiceEnrollment(checkbox) {
const previous = !checkbox.checked;
const status = document.getElementById('supplierServiceStatus');
@ -3760,57 +3761,13 @@ function renderContactActionButtons(number, displayName, contactId) {
}
function buildContactsRows(contacts) {
return contacts.map(contact => {
const displayName = getContactDisplayName(contact);
const contactId = Number(contact.id) || null;
const mobileValue = getContactMobileValue(contact);
const phoneValue = getContactPhoneValue(contact);
const titleValue = String(contact.title || contact.role || '').trim();
const nameCell = contactId
? `<a class="contact-name-link" href="/contacts/${contactId}">${escapeHtml(displayName)}</a>`
: escapeHtml(displayName);
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = phoneValue
? `<div class="contact-phone-wrap"><span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span>${renderContactActionButtons(phoneValue, displayName, contactId)}</div>`
: '—';
const mobile = mobileValue
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span>${renderContactActionButtons(mobileValue, displayName, contactId)}</div>`
: '—';
const title = titleValue ? escapeHtml(titleValue) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
return `
<tr>
<td class="contact-name">${nameCell}</td>
<td>${title}</td>
<td class="contact-email">${email}</td>
<td class="contact-number">${phone}</td>
<td>${mobile}</td>
<td>${primaryBadge}</td>
</tr>
`;
}).join('');
return CompanyContacts.rows(contacts, {name:getContactDisplayName, mobile:getContactMobileValue,
phone:getContactPhoneValue, actions:renderContactActionButtons});
}
function getFilteredContacts() {
const query = contactsSearchQuery.toLowerCase();
return customerContactsData.filter(contact => {
const displayName = getContactDisplayName(contact);
const phoneValue = getContactPhoneValue(contact);
const mobileValue = getContactMobileValue(contact);
const searchable = [
displayName,
String(contact.title || contact.role || ''),
String(contact.email || ''),
phoneValue,
mobileValue
].join(' ').toLowerCase();
const matchesQuery = !query || searchable.includes(query);
const matchesCallable = !contactsOnlyCallable || Boolean(phoneValue || mobileValue);
return matchesQuery && matchesCallable;
});
return CompanyContacts.filter(customerContactsData, contactsSearchQuery, contactsOnlyCallable,
{name:getContactDisplayName,phone:getContactPhoneValue,mobile:getContactMobileValue});
}
function updateContactsResultCount(current, total) {
@ -3832,23 +3789,7 @@ function renderContactsFromState() {
const container = document.getElementById('contactsContainer');
if (!container) return;
const renderContactsTable = (bodyHtml) => `
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
${bodyHtml}
</tbody>
</table>
`;
const renderContactsTable = CompanyContacts.table;
if (!customerContactsData.length) {
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
@ -4179,6 +4120,204 @@ let customerHardware = [];
let hardwareLocationsById = {};
let customerLinks = [];
async function openCustomerAssetEditor(kind, id = null, hardwareId = null) {
const isHardware = kind === 'hardware';
const endpoint = isHardware ? 'hardware' : 'links';
let shell = document.getElementById('customerAssetEditor');
if (!shell) {
shell = document.createElement('div');
shell.id = 'customerAssetEditor';
shell.className = 'modal fade';
shell.tabIndex = -1;
shell.setAttribute('aria-labelledby', 'customerAssetEditorTitle');
shell.innerHTML = `<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"><form class="modal-content"><div class="modal-header"><div><h5 class="modal-title" id="customerAssetEditorTitle"></h5><div class="small text-muted">Knyttet til denne kunde</div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><div role="alert" class="alert alert-danger d-none"></div><div class="row g-3" data-fields></div></div><div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary">Gem</button></div></form></div>`;
document.body.append(shell);
}
const form = shell.querySelector('form');
const fields = shell.querySelector('[data-fields]');
const message = shell.querySelector('[role="alert"]');
const save = form.querySelector('[type="submit"]');
const modal = bootstrap.Modal.getOrCreateInstance(shell);
shell.querySelector('h5').textContent = `${id ? 'Rediger' : 'Opret'} ${isHardware ? 'hardware' : 'link'}`;
message.classList.add('d-none');
fields.textContent = 'Henter oplysninger…';
save.disabled = true;
form.onsubmit = event => event.preventDefault();
modal.show();
const request = async (url, options = {}) => {
const response = await fetch(url, { credentials: 'include', ...options, headers: { ...getAuthHeaders(), 'Content-Type': 'application/json', ...options.headers } });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kunne ikke gemme. Kontrollér felter og adgang.');
return data;
};
try {
const record = id ? await request(`/api/v1/${endpoint}/${Number(id)}`) : {};
const definitions = isHardware ? [
['asset_type', 'Hardwaretype', ['pc', 'laptop', 'printer', 'skærm', 'telefon', 'server', 'netværk', 'andet'], true],
['status', 'Status', ['active', 'faulty_reported', 'in_repair', 'replaced', 'retired', 'unsupported']],
['brand', 'Mærke'], ['model', 'Model'], ['serial_number', 'Serienummer'], ['customer_asset_id', 'Kundens udstyrsnummer'],
['anydesk_id', 'AnyDesk-ID'], ['anydesk_link', 'AnyDesk-link'], ['notes', 'Noter', 'textarea']
] : [
['name', 'Navn', 'text', true], ['type', 'Type', ['http', 'ssh', 'rdp', 'command'], true],
['url', 'URL / kommando'], ['host', 'Vært / IP-adresse'], ['port', 'Port', 'number'], ['username', 'Brugernavn'],
['environment', 'Miljø', ['prod', 'test', 'dev']], ['hardware_id', 'Tilknyttet hardware', []], ['description', 'Beskrivelse', 'textarea']
];
fields.replaceChildren();
for (const [name, label, type = 'text', required = false] of definitions) {
const wrap = document.createElement('div');
wrap.className = type === 'textarea' ? 'col-12' : 'col-md-6';
const caption = document.createElement('label');
caption.className = 'form-label';
caption.htmlFor = `customerAsset-${name}`;
caption.textContent = label;
const input = document.createElement(Array.isArray(type) ? 'select' : type === 'textarea' ? 'textarea' : 'input');
input.id = caption.htmlFor;
input.name = name;
input.required = required;
input.className = Array.isArray(type) ? 'form-select' : 'form-control';
if (input.tagName === 'INPUT') input.type = type;
if (name === 'port') { input.min = '1'; input.max = '65535'; }
if (Array.isArray(type) && name !== 'hardware_id') {
const options = [...type];
if (record[name] && !options.includes(record[name])) options.push(record[name]);
const labels = {http: 'Webside / administration', rdp: 'Fjernskrivebord (RDP)', ssh: 'Terminal (SSH)', command: 'Kommando', prod: 'Drift', test: 'Test', dev: 'Udvikling'};
options.forEach(value => input.add(new Option(labels[value] || value, value)));
}
if (name === 'hardware_id') {
input.add(new Option('Kun til kunden', ''));
const hardware = await request(`/api/v1/hardware?customer_id=${customerId}`);
hardware.forEach(item => input.add(new Option(`${item.brand || ''} ${item.model || item.asset_type || 'Hardware'} · ${item.serial_number || '#' + item.id}`, item.id)));
const selected = record.hardware_id || hardwareId;
if (selected && !hardware.some(item => Number(item.id) === Number(selected))) {
input.add(new Option('Eksisterende hardwaretilknytning', selected));
}
}
input.value = record[name] ?? (name === 'hardware_id' ? hardwareId || '' : name === 'asset_type' ? 'pc' : name === 'status' ? 'active' : name === 'type' ? 'http' : name === 'environment' ? 'prod' : '');
wrap.append(caption, input);
fields.append(wrap);
}
if (!isHardware) enhanceCustomerLinkEditor(form, fields);
save.disabled = false;
form.onsubmit = async event => {
event.preventDefault();
if (!form.reportValidity()) return;
const payload = Object.fromEntries(new FormData(form));
if (!isHardware) {
if (payload.type === 'rdp' || payload.type === 'ssh') {
payload.url = null;
} else {
payload.host = null;
payload.port = null;
payload.username = null;
}
}
for (const key of ['port', 'hardware_id']) if (key in payload) payload[key] = payload[key] ? Number(payload[key]) : null;
if (isHardware && !id) {
payload.current_owner_type = 'customer';
payload.current_owner_customer_id = customerId;
}
if (!isHardware && !id) payload.customer_id = customerId;
save.disabled = true;
message.classList.add('d-none');
try {
await request(`/api/v1/${endpoint}${id ? '/' + Number(id) : ''}`, { method: id ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
modal.hide();
await (isHardware ? loadCustomerHardware() : loadCustomerLinks());
} catch (error) {
message.textContent = error.message;
message.classList.remove('d-none');
} finally { save.disabled = false; }
};
} catch (error) {
fields.replaceChildren();
message.textContent = error.message;
message.classList.remove('d-none');
}
}
function enhanceCustomerLinkEditor(form, fields) {
const get = name => form.elements.namedItem(name);
const presets = {
http: {label: 'Webside', icon: 'globe', name: 'Administration', port: '', placeholder: 'https://eksempel.dk'},
rdp: {label: 'Fjernskrivebord', icon: 'display', name: 'Fjernskrivebord', port: '3389', placeholder: 'fx 172.16.20.31 eller servernavn'},
ssh: {label: 'SSH / terminal', icon: 'terminal', name: 'SSH', port: '22', placeholder: 'fx 172.16.20.31 eller servernavn'},
command: {label: 'Kommando', icon: 'code-slash', name: 'Kommando', port: '', placeholder: 'Indsæt kommando'}
};
const choices = document.createElement('div');
choices.className = 'col-12 d-flex flex-wrap gap-2';
choices.setAttribute('role', 'group');
choices.setAttribute('aria-label', 'Hvad vil du oprette?');
fields.prepend(choices);
const buttons = {};
for (const [type, preset] of Object.entries(presets)) {
const button = document.createElement('button');
button.type = 'button';
button.innerHTML = `<i class="bi bi-${preset.icon} me-2"></i>${preset.label}`;
button.onclick = () => { get('type').value = type; update(); };
choices.append(button);
buttons[type] = button;
}
get('type').parentElement.hidden = true;
let lastSuggestedName = '';
const suggestion = document.createElement('button');
suggestion.type = 'button';
suggestion.className = 'btn btn-link btn-sm px-0 text-start';
get('name').parentElement.append(suggestion);
const preview = document.createElement('div');
preview.className = 'col-12 small text-muted border-top pt-3';
preview.setAttribute('aria-live', 'polite');
fields.append(preview);
function update() {
const type = get('type').value;
const preset = presets[type];
const remote = type === 'rdp' || type === 'ssh';
for (const [key, button] of Object.entries(buttons)) {
const active = key === type;
button.className = `btn ${active ? 'btn-primary' : 'btn-outline-secondary'}`;
button.setAttribute('aria-pressed', String(active));
}
get('url').parentElement.hidden = remote;
get('host').parentElement.hidden = !remote;
get('port').parentElement.hidden = !remote;
get('username').parentElement.hidden = !remote;
get('url').required = !remote;
get('host').required = remote;
get('url').previousElementSibling.textContent = type === 'command' ? 'Kommando' : 'Webadresse';
get('url').placeholder = preset.placeholder;
get('host').placeholder = preset.placeholder;
get('port').placeholder = preset.port ? `Standard: ${preset.port}` : '';
let help = get('port').parentElement.querySelector('small');
if (!help) {
help = document.createElement('small');
help.className = 'text-muted';
get('port').parentElement.append(help);
}
help.textContent = `Tomt felt bruger standardport ${preset.port}. Egen port bevares.`;
const hardware = get('hardware_id');
const hardwareLabel = hardware.value ? hardware.selectedOptions[0].textContent.split(' · ')[0].trim() : '';
const proposed = `${hardwareLabel ? hardwareLabel + ' · ' : ''}${preset.name}`;
if (!get('name').value || get('name').value === lastSuggestedName) {
get('name').value = proposed;
lastSuggestedName = proposed;
}
suggestion.textContent = `Brug forslag: ${proposed}`;
suggestion.onclick = () => { get('name').value = proposed; lastSuggestedName = proposed; };
preview.textContent = remote
? `${preset.label}: ${get('host').value || 'vælg vært / IP'}:${get('port').value || preset.port}${hardwareLabel ? ' · ' + hardwareLabel : ''}`
: `${preset.label}: ${get('url').value || 'indtast ' + (type === 'command' ? 'kommando' : 'webadresse')}`;
}
for (const name of ['type', 'hardware_id']) get(name).addEventListener('change', update);
for (const name of ['host', 'port', 'url']) get(name).addEventListener('input', update);
get('url').addEventListener('blur', () => {
if (get('type').value !== 'http') return;
const value = get('url').value.trim();
if (value && !/^[a-z][a-z0-9+.-]*:/i.test(value)) get('url').value = 'https://' + value;
update();
});
update();
}
function getHardwareGroupLabel(item, groupBy) {
if (groupBy === 'location') {
return item.location_name || 'Ukendt lokation';
@ -4246,6 +4385,8 @@ function renderHardwareTable(groupBy) {
<td>${escapeHtml(location)}</td>
<td>${escapeHtml(status)}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="openCustomerAssetEditor('hardware', ${Number(item.id)})" title="Rediger hardware"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="openCustomerAssetEditor('link', null, ${Number(item.id)})" title="Opret link til hardware"><i class="bi bi-link-45deg"></i></button>
<a class="btn btn-sm btn-outline-primary" href="/hardware/${item.id}">
<i class="bi bi-eye"></i>
</a>
@ -4350,6 +4491,37 @@ document.addEventListener('change', (event) => {
}
});
async function openCustomerLink(id) {
const link = customerLinks.find(item => Number(item.id) === Number(id));
if (!link) return;
if (link.type === 'rdp') {
const href = window.LinkTargets?.rdpTarget(link);
if (!href) { alert('Linket mangler en gyldig RDP-adresse. Rediger linket først.'); return; }
window.location.href = href;
return;
}
if (link.type === 'http' || link.type === 'ssh') {
const href = window.LinkTargets?.href(link);
if (!href) { alert('Linket mangler en gyldig adresse. Rediger linket først.'); return; }
window.open(href, '_blank', 'noopener,noreferrer');
return;
}
try {
const response = await fetch(`/api/v1/links/${Number(id)}/access`, {
method: 'POST', credentials: 'include',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ action_type: 'open', customer_id: customerId })
});
const result = await response.json();
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kunne ikke åbne linket. Kontrollér din adgang.');
if (link.type === 'command' && result.command_text) {
prompt('Kopiér kommandoen:', result.command_text);
} else {
throw new Error('Linket mangler forbindelsesoplysninger. Rediger linket først.');
}
} catch (error) { alert(error.message); }
}
function renderCustomerLinksTable() {
const container = document.getElementById('customerLinksContainer');
const empty = document.getElementById('customerLinksEmpty');
@ -4382,11 +4554,13 @@ function renderCustomerLinksTable() {
const environment = link.environment || 'prod';
return `
<tr>
<td class="fw-semibold">${escapeHtml(link.name || 'Uden navn')}</td>
<td><button type="button" class="btn btn-link p-0 text-start fw-semibold" onclick="openCustomerLink(${Number(link.id)})">${escapeHtml(link.name || 'Uden navn')}</button></td>
<td><span class="badge text-bg-secondary">${escapeHtml(type)}</span></td>
<td>${escapeHtml(target)}</td>
<td>${escapeHtml(environment)}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-primary" onclick="openCustomerLink(${Number(link.id)})"><i class="bi bi-box-arrow-up-right me-1"></i>${link.type === 'rdp' ? 'Åbn RDP' : link.type === 'command' ? 'Vis kommando' : 'Åbn'}</button>
<button class="btn btn-sm btn-outline-primary" onclick="openCustomerAssetEditor('link', ${Number(link.id)})" title="Rediger link"><i class="bi bi-pencil"></i></button>
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id=${customerId}">
<i class="bi bi-box-arrow-up-right"></i>
</a>

View File

@ -159,7 +159,7 @@
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div><h5 class="modal-title" id="duplicateCustomersModalLabel">Mulige kundedubletter</h5><small class="text-muted">Kun oversigt. Intet flyttes eller slettes her.</small></div>
<div><h5 class="modal-title" id="duplicateCustomersModalLabel">Mulige kundedubletter</h5><small class="text-muted">Vælg hovedkunde og kunder, der skal samles. Gennemse flytningen før du bekræfter.</small></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" id="duplicateCustomersContent"><div class="text-center py-5"><div class="spinner-border text-primary"></div></div></div>
@ -545,6 +545,7 @@ function duplicateRelationSummary(counts) {
}
async function openDuplicateCustomersModal() {
customerMergePreviews.clear();
const modalElement = document.getElementById('duplicateCustomersModal');
const content = document.getElementById('duplicateCustomersContent');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
@ -563,18 +564,21 @@ async function openDuplicateCustomersModal() {
conflicting_economic: ['danger', 'Forskellige e-conomic-relationer – kræver kontrol'],
missing_economic: ['warning', 'Mangler e-conomic-relation']
};
content.innerHTML = `<p class="text-muted small">${groups.length} grupper fundet. Den fremhævede kandidat er kun et forslag baseret på e-conomic-nummer, CVR og oprettelsesdato.</p>` + groups.map(group => {
content.innerHTML = `<p class="text-muted small">${groups.length} grupper fundet. Vælg én hovedkunde og markér de kunder, der skal samles med den. Hovedkundens oplysninger bevares; tomme felter udfyldes, når værdien er entydig. Kildekunder deaktiveres. Eksterne systemer ændres ikke.</p>` + groups.map((group, groupIndex) => {
const [colour, label] = status[group.economic_status] || status.missing_economic;
const rows = group.candidates.map((customer, index) => `
<tr class="${index === 0 ? 'table-light' : ''}">
<td><input class="form-check-input" type="radio" name="merge-target-${groupIndex}" value="${Number(customer.id)}" ${index === 0 ? 'checked' : ''} aria-label="Behold som hovedkunde: ${duplicateEscape(customer.name)}" onchange="resetCustomerMerge(${groupIndex})"></td>
<td><input class="form-check-input merge-source" type="checkbox" value="${Number(customer.id)}" checked aria-label="Medtag kunde: ${duplicateEscape(customer.name)}" onchange="resetCustomerMerge(${groupIndex})"></td>
<td><a href="/customers/${customer.id}" class="fw-semibold">${duplicateEscape(customer.name)}</a>${index === 0 ? '<span class="badge text-bg-primary ms-2">Foreslået hovedkunde</span>' : ''}</td>
<td>${duplicateEscape(customer.economic_customer_number)}</td>
<td>${duplicateEscape(customer.cvr_number)}</td>
<td class="small text-muted">${duplicateRelationSummary(customer.relation_counts)}</td>
</tr>`).join('');
return `<section class="border rounded-3 mb-3 overflow-hidden">
return `<section id="customer-merge-group-${groupIndex}" class="border rounded-3 mb-3 overflow-hidden">
<div class="d-flex justify-content-between gap-3 align-items-center px-3 py-2 bg-light"><strong>${duplicateEscape(group.candidates?.[0]?.name || group.key)}</strong><span class="badge text-bg-${colour}">${label}</span></div>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Kunde</th><th>e-conomic</th><th>CVR</th><th>Data knyttet til posten</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Hovedkunde</th><th>Medtag</th><th>Kunde</th><th>e-conomic</th><th>CVR</th><th>Data knyttet til posten</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="p-3 border-top"><button class="btn btn-sm btn-primary" type="button" onclick="previewCustomerMerge(${groupIndex}, this)">Vis sammenlægning</button><div class="merge-preview mt-2" aria-live="polite"></div></div>
</section>`;
}).join('');
} catch (error) {
@ -582,6 +586,107 @@ async function openDuplicateCustomersModal() {
}
}
const customerMergePreviews = new Map();
function resetCustomerMerge(index) {
customerMergePreviews.delete(index);
document.querySelector(`#customer-merge-group-${index} .merge-preview`).replaceChildren();
}
function customerMergeSelection(index) {
const section = document.getElementById(`customer-merge-group-${index}`);
const target_id = Number(section.querySelector('input[type=radio]:checked').value);
const source_ids = Array.from(section.querySelectorAll('.merge-source:checked'), input => Number(input.value)).filter(id => id !== target_id);
if (!source_ids.length) throw new Error('Markér mindst én anden kunde, der skal samles med hovedkunden.');
return { target_id, source_ids };
}
async function customerMergeRequest(path, body) {
const response = await fetch(`/api/v1/customers/merge${path}`, {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kunne ikke samle kunderne. Kontrollér dit valg.');
return data;
}
function customerMergeLabel(table) {
const labels = {contact_companies: 'Kontakttilknytninger', sag_sager: 'Sager', sag_kunder: 'Ekstra sagstilknytninger',
ordre_drafts: 'Ordrer og fakturakladder', supplier_invoices: 'Leverandørfakturaer', supplier_invoice_lines: 'Fakturalinjer',
entity_tags: 'Tags', alert_notes: 'Vigtige kundenoter', customer_notes: 'Kundenoter',
hardware_assets: 'Hardware', hardware_ownership_history: 'Hardwarehistorik',
tmodule_customers: 'Koblinger til tidsmodulet', tmodule_orders: 'Ordrekoblinger',
sag_subscriptions: 'Abonnementer', locations_locations: 'Lokationer',
email_messages: 'E-mails', conversations: 'Samtaler', tticket_tickets: 'Tickets',
quick_time_items: 'Tidsregistrering og planlægning', customers: 'Kundehenvisninger',
bmc_workflows: 'Kundeworkflows', customer_relationships: 'Kundeforbindelser',
bmc_office_subscriptions: 'Office-abonnementer', customer_fixed_price_agreements: 'Fastprisaftaler',
customer_vendor_links: 'Leverandørtilknytninger', email_domain_customer_mappings: 'E-maildomæner',
email_templates: 'E-mailskabeloner', hardware: 'Hardwaretilknytninger',
customer_house_organizations: 'Virksomheder i huset', contact_location_users: 'Brugere i huset',
economic_price_rules: 'Prisregler', pipeline_opportunities: 'Salgsmuligheder',
task_templates: 'Opgaveskabeloner', knowledge_articles: 'Vidensartikler',
locations_wall_outlets: 'Netværksudtag', links: 'Links og adgange',
link_access_log: 'Adgangshistorik', link_runbooks: 'Driftsvejledninger'};
if (labels[table]) return labels[table];
const families = {also: 'ALSO-tilknytninger', anydesk: 'AnyDesk', bmc_workflow: 'Workflowtilknytninger',
drift: 'Driftstilknytninger', internet_connections: 'Internetforbindelser', invoice_error_finder: 'Fakturakontrol',
migration_center: 'Importerede kundetilknytninger', nextcloud: 'Nextcloud', simply: 'Simply-abonnementer',
vtiger: 'vTiger-tilknytninger', webshop: 'Webshop', tticket: 'Tickets og klippekort',
workflow_tag: 'Taghistorik', fedex: 'FedEx-forsendelser', shipmondo: 'Shipmondo-forsendelser'};
return Object.entries(families).find(([prefix]) => table.startsWith(prefix))?.[1] || 'Øvrige kundetilknytninger';
}
function customerMergeField(field) {
return ({email:'E-mail', phone:'Telefon', address:'Adresse', email_domain:'E-maildomæne', city:'By',
postal_code:'Postnummer', country:'Land', website:'Website', invoice_email:'Faktura-e-mail',
mobile_phone:'Mobil', wiki_slug:'Wiki', economic_customer_number:'e-conomic-kundenummer',
vtiger_id:'vTiger-id', vtiger_account_no:'vTiger-kundenummer', cvr_number:'CVR', name:'Navn',
payment_terms_number:'Betalingsbetingelser', payment_terms_type:'Betalingstype', vat_zone_number:'Momszone',
customer_group_number:'Kundegruppe', barred:'Spærring', currency_code:'Valuta', ean:'EAN',
public_entry_number:'Offentligt kundenummer', subscriptions_locked:'Abonnementslås',
standard_margin_percent:'Avance', special_freight_price:'Fragtpris', supplier_service_enrolled:'Leverandørservice',
invoice_fee_amount:'Fakturagebyr', invoice_fee_exempt:'Gebyrfritagelse', standard_hourly_rate:'Timepris',
is_bmc_customer:'Kundestatus', show_in_customer_list:'Visning i kundelisten',is_active:'Aktiv status'})[field] || 'Kundeoplysning';
}
async function previewCustomerMerge(index, button) {
const area = document.querySelector(`#customer-merge-group-${index} .merge-preview`);
resetCustomerMerge(index);
button.disabled = true;
const section = document.getElementById(`customer-merge-group-${index}`);
section.querySelectorAll('input').forEach(input => input.disabled = true);
area.textContent = 'Kontrollerer alle relationer og mulige konflikter…';
try {
const selection = customerMergeSelection(index);
const data = await customerMergeRequest('/preview', selection);
customerMergePreviews.set(index, {...selection, preview_token: data.preview_token});
const main = data.customers.find(customer => customer.id === data.target_id);
const total = data.relations.reduce((sum, item) => sum + item.count, 0);
area.innerHTML = `<div class="alert alert-info mb-2"><strong>${data.source_ids.length} kunder samles under ${duplicateEscape(main.name)} (#${data.target_id})</strong><div>${total} relationer berøres. Sager, kontakter og lokale økonomirelationer følger med. Fakturaer i e-conomic forbliver på det eksisterende kundenummer.</div></div>` +
`<ul class="small mb-2">${data.relations.map(item => `<li>${duplicateEscape(customerMergeLabel(item.table))}: ${item.count}</li>`).join('')}</ul>` +
(data.filled_fields.length ? `<div class="small">Tomme felter udfyldes: ${duplicateEscape(data.filled_fields.map(customerMergeField).join(', '))}.</div>` : '') +
(data.kept_main_fields.length ? `<div class="small text-warning">Forskellige oplysninger: ${duplicateEscape(data.kept_main_fields.map(customerMergeField).join(', '))}. Hovedkundens værdier bevares; originalerne gemmes i historikken.</div>` : '') +
`<div class="small text-muted my-2">Kildekunder deaktiveres og knyttes til hovedkunden. Flytningen og de oprindelige kundeoplysninger gemmes i historikken.</div><button type="button" class="btn btn-sm btn-success" onclick="confirmCustomerMerge(${index}, this)">Bekræft sammenlægning af ${data.source_ids.length + 1} kunder</button>`;
} catch (error) {
area.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)}</div>`;
} finally {
button.disabled = false;
section.querySelectorAll('input').forEach(input => input.disabled = false);
}
}
async function confirmCustomerMerge(index, button) {
const preview = customerMergePreviews.get(index);
if (!preview) return;
button.disabled = true;
const area = document.querySelector(`#customer-merge-group-${index} .merge-preview`);
try {
const result = await customerMergeRequest('', preview);
customerMergePreviews.delete(index);
const section = document.getElementById(`customer-merge-group-${index}`);
section.querySelectorAll('input, button').forEach(input => input.disabled = true);
area.innerHTML = `<div class="alert alert-success mb-0">Kunderne er samlet. <a href="/customers/${result.target_id}">Åbn hovedkunden</a>. Historik #${result.history_id}.</div>`;
loadCustomers();
} catch (error) {
customerMergePreviews.delete(index);
area.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)} Vis sammenlægningen igen før et nyt forsøg.</div>`;
}
}
function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
}

View File

@ -22,7 +22,7 @@
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
padding: 0.75rem;
background: white;
background: var(--theme-surface, white);
margin-bottom: 0.75rem;
}

View File

@ -752,7 +752,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
state = 'ringing',
ended_at = NULL,
answered_at = NULL,
started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at),
started_at = EXCLUDED.started_at,
updated_at = NOW(),
last_payload = jsonb_set(
EXCLUDED.last_payload,

View File

@ -27,9 +27,9 @@ class MissionService:
@staticmethod
def get_ring_timeout_seconds() -> int:
# A Yealink "Incoming call" event has no matching event when the
# caller abandons the call. Keep it briefly, then remove it from the
# live boards so stale ringing phones cannot remain visible.
return 30
# caller abandons the call. Phones may not emit another event while
# ringing, so allow a full ring window before clearing a stale entry.
return 120
@staticmethod
def expire_stale_ringing_calls() -> None:
@ -44,7 +44,7 @@ class MissionService:
ended_at = COALESCE(ended_at, NOW()),
updated_at = NOW()
WHERE state = 'ringing'
AND started_at < (NOW() - make_interval(secs => %s))
AND updated_at < (NOW() - make_interval(secs => %s))
""",
(timeout_seconds,),
)
@ -211,7 +211,6 @@ class MissionService:
logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
return []
MissionService.expire_stale_ringing_calls()
rows = execute_query(
"""
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at,
@ -1475,7 +1474,7 @@ class MissionService:
return {
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, None),
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),

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

@ -37,7 +37,7 @@
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
display: block;
background: white;
background: var(--theme-surface, white);
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
@ -100,7 +100,7 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
color: var(--theme-muted, #64748b);
margin-bottom: 0.75rem;
}
@ -124,22 +124,22 @@
.trend-badge.positive {
background: linear-gradient(135deg, #d4fc79 0%, #96e6a1 100%);
color: #166534;
color: var(--theme-success-text, #166534);
}
.trend-badge.negative {
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
color: #991b1b;
color: var(--theme-danger-text, #991b1b);
}
.trend-badge.neutral {
background: #e2e8f0;
color: #64748b;
background: var(--theme-surface-soft, #e2e8f0);
color: var(--theme-muted, #64748b);
}
/* Content Cards */
.content-card {
background: white;
background: var(--theme-surface, white);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
@ -281,7 +281,7 @@
}
.activity-feed::-webkit-scrollbar-track {
background: #f1f5f9;
background: var(--theme-surface-soft, #f1f5f9);
}
.activity-feed::-webkit-scrollbar-thumb {

View File

@ -54,7 +54,7 @@
height: 200px;
object-fit: cover;
border-radius: 8px;
background: #f8f9fa;
background: var(--theme-surface-soft, #f8f9fa);
}
</style>
{% endblock %}

View File

@ -1,3 +1,4 @@
from app.services.invoice_fee_service import with_invoice_fee, invoice_fee_line
import logging
from collections import defaultdict
from datetime import date
@ -23,6 +24,8 @@ class BulkIdsRequest(BaseModel):
class BulkUpdateRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
description: Optional[str] = None
prepaid_card_id: Optional[int] = Field(None, gt=0)
fixed_price_agreement_id: Optional[int] = Field(None, gt=0)
original_hours: Optional[float] = Field(None, gt=0)
billable: Optional[bool] = None
billing_method: Optional[str] = None
@ -59,6 +62,7 @@ class SettlementRequest(BaseModel):
billing_method: Optional[str] = None
prepaid_card_id: Optional[int] = Field(None, gt=0)
fixed_price_agreement_id: Optional[int] = Field(None, gt=0)
line_mode: str = Field(default="separate", pattern="^(separate|combined)$")
VALID_SETTLEMENT_METHODS = {"invoice", "prepaid", "subscription", "internal", "non_billable"}
@ -114,6 +118,8 @@ async def list_hub_time_queue(
billable: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(500, ge=1, le=2000),
scope: Optional[str] = Query(None, pattern="^(active|history|all)$"),
request: Request = None,
):
"""List non-billed Hub-created time entries for the economy queue."""
try:
@ -123,6 +129,12 @@ async def list_hub_time_queue(
"t.economy_order_draft_id IS NULL",
"t.status <> 'billed'",
]
if scope in {'active', 'history', 'all'}:
conditions = ["t.vtiger_id IS NULL", "t.status <> 'rejected'"]
if scope == 'active':
conditions += ["t.billed_via_thehub_id IS NULL", "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", "t.economy_settled_at IS NULL"]
elif scope == 'history':
conditions.append("(t.status IN ('approved','billed') OR t.economy_order_draft_id IS NOT NULL OR t.billed_via_thehub_id IS NOT NULL OR t.economy_settled_at IS NOT NULL)")
params: List[Any] = []
# A time entry may retain an old customer_id after a case has been
@ -193,8 +205,13 @@ async def list_hub_time_queue(
COALESCE(NULLIF(u.full_name, ''), NULLIF(u.username, ''), NULLIF(t.user_name, ''), 'Ukendt medarbejder') AS employee_name,
CONCAT_WS(' ', NULLIF(cont.first_name, ''), NULLIF(cont.last_name, '')) AS contact_name,
t.created_at,
t.updated_at
t.updated_at,
t.workflow_revision,
t.economy_order_draft_id, t.economy_settled_at, t.billed_via_thehub_id,
draft.sync_status AS order_sync_status,
draft.economic_invoice_number
FROM tmodule_times t
LEFT JOIN ordre_drafts draft ON draft.id = t.economy_order_draft_id
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
@ -223,7 +240,25 @@ async def list_hub_time_queue(
params.append(limit)
rows = execute_query(query, tuple(params))
return {"items": rows, "count": len(rows)}
_attach_case_tags(rows)
if settings.WORKFLOWS_ENABLED:
_attach_workflow_decisions(rows)
if scope in {"active", "history", "all"}:
from app.core.auth_dependencies import get_current_user
actor = await _queue_actor(request) if request is not None else {}
permissions = actor.get('permissions') or []
can_override = actor.get('is_superadmin') or all(p in permissions for p in ('workflows.override','workflows.decisions.view'))
for row in rows:
row.update(_queue_lifecycle(row))
row['entry_fingerprint'] = _queue_review_fingerprint(row)
row['workflow_can_override'] = bool(can_override and row['can_change_billing'])
can_approve = bool(actor.get('is_superadmin') or 'billing.approve' in permissions)
if row.get('workflow_active'):
can_approve = can_approve and bool(actor.get('is_superadmin') or 'workflows.settle' in permissions)
row['can_approve'] = bool(row['can_approve'] and can_approve)
row['can_settle'] = bool(row['can_settle'] and can_approve)
row['can_change_billing'] = bool(row['can_change_billing'] and (can_override if row.get('workflow_active') else actor.get('is_superadmin') or 'billing.edit' in permissions))
return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED}
except HTTPException:
raise
except Exception as e:
@ -295,8 +330,31 @@ async def list_prepaid_cards(customer_id: Optional[int] = Query(None, gt=0)):
@router.patch("/time-queue/bulk-update")
async def bulk_update_time_queue(payload: BulkUpdateRequest):
async def bulk_update_time_queue(payload: BulkUpdateRequest, request: Request = None):
if {'prepaid_card_id', 'fixed_price_agreement_id'}.intersection(payload.model_fields_set):
from app.core.database import atomic_transaction
from app.core.auth_dependencies import require_permission
actor = await _queue_actor(request)
await require_permission('billing.edit')(actor)
with atomic_transaction():
execute_query('SELECT id FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (_ensure_ids(payload.ids),))
return await _bulk_update_time_queue(payload)
return await _bulk_update_time_queue(payload)
async def _bulk_update_time_queue(payload: BulkUpdateRequest):
ids = _ensure_ids(payload.ids)
if payload.billing_method is not None or payload.billable is not None or {"prepaid_card_id","fixed_price_agreement_id"}.intersection(payload.model_fields_set):
_protect_workflow_selection(ids)
# Financial choices are frozen once the employee's registration is reviewed.
financial_edit = bool({'prepaid_card_id','fixed_price_agreement_id'}.intersection(payload.model_fields_set))
if financial_edit:
current = execute_query('SELECT id,status,economy_order_draft_id,economy_settled_at,billed_via_thehub_id FROM tmodule_times WHERE id=ANY(%s)', (ids,)) or []
if len(current) != len(ids) or any(row.get('status') in {'approved','billed','rejected'} or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('billed_via_thehub_id') for row in current):
raise HTTPException(409, 'Afregning kan kun ændres før godkendelse')
if payload.billing_method is not None and _normalise_billing_method(payload.billing_method) not in VALID_SETTLEMENT_METHODS | {'draft','operations','development'}:
raise HTTPException(422, 'Ugyldig afregningsmetode')
updates: List[str] = []
values: List[Any] = []
@ -319,6 +377,13 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest):
updates.append("billing_method = %s")
values.append(payload.billing_method)
if "prepaid_card_id" in payload.model_fields_set:
updates.append("prepaid_card_id = %s")
values.append(payload.prepaid_card_id)
if "fixed_price_agreement_id" in payload.model_fields_set:
updates.append("fixed_price_agreement_id = %s")
values.append(payload.fixed_price_agreement_id)
if not updates:
raise HTTPException(status_code=400, detail="No update fields provided")
@ -332,9 +397,18 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest):
AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed'
{"AND status NOT IN ('approved','rejected') AND economy_settled_at IS NULL" if financial_edit else ""}
RETURNING id
"""
execute_update(query, tuple(values + ids))
if financial_edit:
changed = execute_query(query, tuple(values + ids)) or []
if len(changed) != len(ids):
raise HTTPException(409, "En tid blev ændret under behandlingen. Genindlæs køen.")
else:
execute_update(query, tuple(values + ids))
return {"success": True, "updated": len(ids)}
except HTTPException:
raise
except Exception as e:
logger.error("Failed bulk update: %s", e)
raise HTTPException(status_code=500, detail="Failed bulk update")
@ -371,6 +445,7 @@ async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
@router.post("/time-queue/bulk-approve")
async def bulk_approve_time_queue(payload: BulkApproveRequest):
ids = _ensure_ids(payload.ids)
_protect_workflow_selection(ids)
try:
set_parts = [
@ -410,6 +485,7 @@ async def bulk_approve_time_queue(payload: BulkApproveRequest):
@router.post("/time-queue/bulk-apply-prepaid")
async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
ids = _ensure_ids(payload.ids)
_protect_workflow_selection(ids)
card = execute_query_single(
"SELECT id FROM tticket_prepaid_cards WHERE id = %s",
@ -494,7 +570,10 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
)
total_hours += qty
fee = invoice_fee_line(customer.get("hub_customer_id"))
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
if fee:
subtotal += Decimal(str(fee["amount"]))
vat_rate = Decimal("25.00")
vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
total_amount = subtotal + vat_amount
@ -545,10 +624,18 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
),
)
if fee:
execute_insert("""INSERT INTO tmodule_order_lines
(order_id, line_number, description, quantity, unit_price, line_total,
time_entry_ids, product_number, product_id, source_type)
VALUES (%s, %s, %s, 1, %s, %s, %s, %s, %s, 'invoice_fee') RETURNING id""",
(order_id, len(line_payloads) + 1, fee['description'], fee['unit_price'],
fee['amount'], [], fee['product_number'], fee['product_id']))
return int(order_id)
def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int], *, line_mode: Optional[str] = None) -> int:
"""Create an order draft for the actual Hub customer on the case.
`tmodule_customers` is legacy tracking data and can contain stale names or
@ -557,7 +644,7 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str,
hub_customer = execute_query_single(
"""
SELECT id, name, standard_hourly_rate, standard_margin_percent,
special_freight_price, supplier_service_enrolled, invoice_fee_amount
special_freight_price, supplier_service_enrolled, invoice_fee_amount, invoice_fee_exempt
FROM customers
WHERE id = %s
""",
@ -569,13 +656,6 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str,
customer_name = hub_customer.get("name") or f"Kunde {hub_customer_id}"
hourly_rate = Decimal(str(hub_customer.get("standard_hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
invoice_fee_amount = Decimal(
str(
(hub_customer or {}).get("invoice_fee_amount")
if (hub_customer or {}).get("invoice_fee_amount") is not None
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
)
)
special_freight_price = (hub_customer or {}).get("special_freight_price")
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
@ -603,6 +683,10 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str,
for row in rows:
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
if line_mode == "separate":
group_key = str(row['id'])
elif line_mode == "combined":
group_key = str(row.get('workflow_product_number') or '')
grouped[group_key]["rows"].append(row)
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
grouped[group_key]["case_id"] = row.get("case_id")
@ -625,6 +709,17 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str,
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
amount = (qty * unit_price).quantize(Decimal("0.01"))
workflow_product_number = group['rows'][0].get('workflow_product_number')
workflow_product_id = None
if line_mode is not None and workflow_product_number:
products = execute_query('''SELECT id FROM products WHERE deleted_at IS NULL
AND status='active' AND lifecycle_status NOT IN ('inactive', 'phasing_out')
AND is_active_in_economic IS DISTINCT FROM FALSE
AND (economic_product_number=%s OR sku_internal=%s)''',
(workflow_product_number, workflow_product_number)) or []
if len(products) != 1:
raise HTTPException(409, 'Workflowets varenummer skal være knyttet til præcis én aktiv Hub-vare')
workflow_product_id = int(products[0]['id'])
line_payloads.append(
{
@ -636,7 +731,9 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str,
"unit_price": float(unit_price),
"discount_percentage": 0,
"unit": "timer",
"product_id": None,
"product_id": workflow_product_id,
"product_number": workflow_product_number,
"economic_product_number": workflow_product_number,
"selected": True,
"amount": float(amount),
"customer_id": int(hub_customer_id),
@ -673,32 +770,7 @@ def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str,
}
)
# Fee line is included by default unless customer-specific value is 0.
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
"line_key": f"invoice_fee:{hub_customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
"quantity": 1.0,
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"discount_percentage": 0,
"unit": "stk",
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
"meta": {
"standard_margin_percent": float(standard_margin_percent),
"supplier_service_enrolled": supplier_service_enrolled,
},
}
)
line_payloads = with_invoice_fee(line_payloads, hub_customer_id, legacy=True, customer=hub_customer)
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
@ -850,6 +922,8 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail="Ugyldig afregningstype")
rows = _selected_time_entries(ids)
if settings.WORKFLOWS_ENABLED:
_attach_workflow_decisions(rows)
found_ids = {int(row["id"]) for row in rows}
missing_ids = [entry_id for entry_id in ids if entry_id not in found_ids]
items: List[Dict[str, Any]] = []
@ -863,6 +937,7 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
for row in rows:
method = override_method or _normalise_billing_method(row.get("billing_method"))
workflow_decision = row.get('workflow_decision')
hours = float(row.get("approved_hours") or row.get("original_hours") or 0)
item = {
"id": int(row["id"]),
@ -876,7 +951,11 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
"valid": True,
"message": None,
}
if method not in VALID_SETTLEMENT_METHODS:
if row.get('workflow_active') and (not workflow_decision or workflow_decision.get('status') not in {'decided','overridden'}):
item.update(valid=False, message="Afventer workflowbeslutning eller manuel vurdering")
elif row.get('workflow_stale'):
item.update(valid=False, message="Workflowbeslutningen skal genberegnes")
elif method not in VALID_SETTLEMENT_METHODS:
item.update(valid=False, message="Vælg en gyldig afregningstype")
elif hours <= 0:
item.update(valid=False, message="Tiden mangler et positivt timeantal")
@ -972,6 +1051,19 @@ async def preview_time_queue_settlement(payload: SettlementRequest):
@router.post("/time-queue/settle")
async def settle_time_queue(payload: SettlementRequest, request: Request):
"""Complete a previously reviewable settlement without mixing payment methods."""
if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active':
from app.workflows.integration import active_for_customer
from app.workflows.settlement import workflow_customer, settle_workflow_entries
scoped = [active_for_customer(workflow_customer(tid)) for tid in payload.ids]
if any(scoped):
if not all(scoped):
raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig')
if payload.billing_method or payload.prepaid_card_id or payload.fixed_price_agreement_id:
raise HTTPException(409, 'Brug begrundet overstyring af workflowbeslutningen før afregning')
from app.core.auth_dependencies import get_current_user, require_permission
actor = await get_current_user(request, None)
await require_permission('workflows.settle')(actor)
return settle_workflow_entries(payload.ids, actor['id'], payload.line_mode)
preview = _build_settlement_preview(payload)
if not preview["valid"]:
raise HTTPException(status_code=409, detail={"message": "Ret fejlene før afregning", "preview": preview})
@ -1195,3 +1287,231 @@ async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
except Exception as e:
logger.error("Failed send-to-invoices flow: %s", e)
raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")
def _protect_workflow_selection(ids):
if not (settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE=='active'):
return
from app.workflows.integration import active_for_customer
from app.workflows.settlement import workflow_customer
if any(active_for_customer(workflow_customer(tid)) for tid in ids):
raise HTTPException(409, 'Brug workflowbeslutningens begrundede overstyring og afregning')
def _attach_case_tags(rows):
"""Load tags once for all visible cases, including legacy case-only tags."""
case_ids = sorted({int(row['sag_id']) for row in rows if row.get('sag_id')})
by_case = {}
if case_ids:
tags = execute_query('''SELECT et.entity_id AS sag_id,t.id,t.name,t.color,t.icon
FROM entity_tags et JOIN tags t ON t.id=et.tag_id
WHERE et.entity_type='case' AND et.entity_id=ANY(%s)
ORDER BY t.type,t.name,t.id''', (case_ids,)) or []
for tag in tags:
by_case.setdefault(int(tag['sag_id']), []).append({key: tag.get(key) for key in ('id','name','color','icon')})
missing = [case_id for case_id in case_ids if case_id not in by_case]
if missing:
legacy = execute_query('SELECT sag_id,id,tag_navn AS name FROM sag_tags WHERE sag_id=ANY(%s) AND deleted_at IS NULL ORDER BY tag_navn,id', (missing,)) or []
for tag in legacy:
by_case.setdefault(int(tag['sag_id']), []).append({'id':tag['id'],'name':tag['name'],'color':None,'icon':None})
for row in rows:
row['case_tags'] = by_case.get(int(row['sag_id']), []) if row.get('sag_id') else []
def _attach_workflow_decisions(rows):
if not rows:
return
from app.workflows.integration import active_for_customer
ids=[int(row['id']) for row in rows]
decisions=execute_query('''SELECT d.*,t.workflow_revision AS entry_revision FROM bmc_billing_decisions d
JOIN tmodule_times t ON t.id=d.time_id WHERE d.time_id=ANY(%s) AND d.is_current''',(ids,)) or []
by_id={int(d['time_id']):d for d in decisions}
customer_activation = {}
for row in rows:
decision=by_id.get(int(row['id']))
row['workflow_decision']=decision
customer_id = row.get('billing_customer_id') or row.get('customer_id')
if customer_id not in customer_activation:
customer_activation[customer_id] = active_for_customer(customer_id)
row['workflow_active']=customer_activation[customer_id]
row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision'])
if row['workflow_active'] and decision and decision.get('method') and not (row.get('economy_settled_at') or row.get('economy_order_draft_id') or row.get('billed_via_thehub_id')):
row['billing_method']=decision['method']
row['billable']=decision['method'] in ('invoice','prepaid')
config=(decision.get('details') or {}).get('billing') or (decision.get('details') or {}).get('decision') or {}
row['prepaid_card_id']=config.get('card_id')
row['fixed_price_agreement_id']=config.get('agreement_id')
class ReviewedTimeSnapshot(BaseModel):
id: int = Field(..., gt=0)
updated_at: str
entry_fingerprint: str
billing_method: str
prepaid_card_id: Optional[int] = None
fixed_price_agreement_id: Optional[int] = None
workflow_revision: Optional[int] = None
workflow_decision_id: Optional[int] = None
class ReviewedTimeRequest(BaseModel):
model_config = {'extra': 'forbid'}
ids: List[int] = Field(..., min_length=1)
snapshots: List[ReviewedTimeSnapshot] = Field(..., min_length=1)
line_mode: str = Field(default='separate', pattern='^(separate|combined)$')
def _queue_lifecycle(row):
method = _normalise_billing_method(row.get('billing_method'))
sent = bool(row.get('economy_order_draft_id') or row.get('billed_via_thehub_id'))
invoice = bool(row.get('economic_invoice_number')) or row.get('order_sync_status') in {'posted', 'paid'}
settled = bool(row.get('economy_settled_at') or row.get('status') == 'billed')
if method != 'invoice' and settled:
stage, label = 'settled', 'Afregnet'
elif invoice:
stage, label = 'invoiced', 'Faktureret'
elif sent:
stage, label = 'sent', 'Sendt til fakturering'
elif row.get('status') == 'approved':
stage, label = 'approved', 'Godkendt'
elif row.get('status') == 'billed':
# Legacy "billed" alone does not establish a verified invoice.
stage, label = 'settled', 'Afregnet'
else:
stage, label = 'pending', 'Afventer godkendelse'
return dict(lifecycle_status=stage, lifecycle_label=label,
can_approve=stage == 'pending' and row.get('status') != 'rejected',
can_change_billing=stage == 'pending' and row.get('status') != 'rejected',
can_settle=stage in {'pending', 'approved'} and row.get('status') != 'rejected',
order_url=f"/ordre/{int(row['economy_order_draft_id'])}" if row.get('economy_order_draft_id') else None)
def _validate_reviewed_rows(payload, raw):
ids = _ensure_ids(payload.ids)
snapshots = {item.id: item for item in payload.snapshots}
if len(snapshots) != len(payload.snapshots) or set(snapshots) != set(ids):
raise HTTPException(422, 'Der skal være præcis én gennemgået registrering for hver valgt tid')
if {int(row['id']) for row in raw} != set(ids):
raise HTTPException(404, 'En tidsregistrering findes ikke længere')
if any(row.get('vtiger_id') or row.get('billed_via_thehub_id') or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('status') in {'billed', 'rejected'} for row in raw):
raise HTTPException(409, 'En valgt tid er allerede behandlet eller afvist. Genindlæs køen.')
selected = _selected_time_entries(ids)
if settings.WORKFLOWS_ENABLED:
_attach_workflow_decisions(selected)
by_id = {int(row['id']): row for row in selected}
for row in raw:
snapshot = snapshots[int(row['id'])]
current = by_id.get(int(row['id']))
if not current:
raise HTTPException(409, 'Tiden findes ikke længere i køen')
if snapshot.entry_fingerprint != _queue_review_fingerprint(current):
raise HTTPException(409, 'Tid, kunde eller beskrivelse er ændret. Genindlæs køen.')
# Compare the database timestamp, not a rounded display value.
timestamp = row.get('updated_at')
from datetime import datetime
try:
reviewed_stamp = datetime.fromisoformat(snapshot.updated_at.replace('Z', '+00:00'))
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
except (ValueError, TypeError):
raise HTTPException(409, 'Genindlæs tiden før godkendelse')
if timestamp != reviewed_stamp:
raise HTTPException(409, 'Tiden er ændret siden gennemgangen. Genindlæs køen.')
for key in ('billing_method', 'prepaid_card_id', 'fixed_price_agreement_id'):
if getattr(snapshot, key) != current.get(key):
raise HTTPException(409, 'Afregningsvalget er ændret. Genindlæs køen.')
if current.get('workflow_active') and snapshot.workflow_revision != int(row.get('workflow_revision') or 0):
raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.')
if current.get('workflow_active') and snapshot.workflow_decision_id != (current.get('workflow_decision') or {}).get('id'):
raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.')
if current.get('workflow_active') and current.get('workflow_stale'):
raise HTTPException(409, 'Workflowbeslutningen er forældet')
for current in selected:
if current.get('workflow_active'):
from app.workflows.settlement import current_decision
from app.workflows.service import load_time_context
from app.workflows.classification import enrich_time_context
from app.core.database import _transaction_connection
from psycopg2.extras import RealDictCursor
current_decision(current['id'], by_id[current['id']].get('workflow_decision', {}).get('entry_revision'))
with _transaction_connection.get().cursor(cursor_factory=RealDictCursor) as cursor:
context = enrich_time_context(cursor, load_time_context(current['id'], cursor))
if context.get('pending_billing_tag_suggestions'):
raise HTTPException(409, 'Afvent godkendelse eller afvisning af relevante tags')
preview = _build_settlement_preview(SettlementRequest(ids=ids, line_mode=payload.line_mode))
if not preview['valid']:
raise HTTPException(409, {'message': 'Ret fejlene før godkendelse', 'preview': preview})
return selected
@router.post('/time-queue/approve-reviewed')
async def approve_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request):
from app.core.auth_dependencies import get_current_user, require_permission
from app.core.database import atomic_transaction
actor = await _queue_actor(request)
await require_permission('billing.approve')(actor)
ids = _ensure_ids(payload.ids)
with atomic_transaction():
raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or []
rows = _validate_reviewed_rows(payload, raw)
if any(row.get('workflow_active') for row in rows):
await require_permission('workflows.settle')(actor)
changed = [int(row['id']) for row in raw if row.get('status') != 'approved']
if changed:
execute_update("""UPDATE tmodule_times SET status='approved',entry_status='godkendt',
approved_hours=COALESCE(approved_hours,original_hours), approved_at=now(),approved_by=%s,
updated_at=now(),aktiv_timer=false WHERE id=ANY(%s)""", (actor['id'], changed))
return {'success': True, 'approved': len(changed), 'already_approved': len(ids)-len(changed),
'message': 'Tiderne er godkendt. Der er ikke trukket timer eller oprettet ordrekladder.'}
@router.post('/time-queue/send-reviewed')
async def send_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request):
"""Revalidate the reviewed selection under locks before any local financial effects."""
from app.core.auth_dependencies import get_current_user, require_permission
from app.core.database import atomic_transaction
from app.workflows.integration import active_for_customer
from app.workflows.settlement import workflow_customer, settle_workflow_entries
actor = await _queue_actor(request)
await require_permission('billing.approve')(actor)
ids = _ensure_ids(payload.ids)
scoped = [active_for_customer(workflow_customer(tid)) for tid in ids] if settings.WORKFLOWS_ENABLED else [False]*len(ids)
if any(scoped):
if not all(scoped):
raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig')
await require_permission('workflows.settle')(actor)
return settle_workflow_entries(ids, actor['id'], payload.line_mode,
review_validator=lambda raw: _validate_reviewed_rows(payload, raw))
with atomic_transaction():
raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or []
rows = _validate_reviewed_rows(payload, raw)
if any(row.get('workflow_active') for row in rows):
raise HTTPException(409, 'Workflowaktivering er ændret. Genindlæs køen.')
return await settle_time_queue(SettlementRequest(ids=ids, line_mode=payload.line_mode), request)
async def _queue_actor(request):
from app.core.auth_dependencies import get_current_user
from fastapi.security import HTTPAuthorizationCredentials
header = request.headers.get('authorization', '')
credentials = None
if header.lower().startswith('bearer '):
credentials = HTTPAuthorizationCredentials(scheme='Bearer', credentials=header[7:].strip())
return await get_current_user(request, credentials)
def _queue_review_fingerprint(row):
import hashlib
fields = ('billing_method','prepaid_card_id','fixed_price_agreement_id','original_hours',
'approved_hours','faktisk_tid_min','fakturerbar_tid_min','round_block_min',
'worked_date','description','billing_customer_id','sag_id','case_id','billable')
values = {}
for field in fields:
value = row.get(field)
if field in {'original_hours','approved_hours'} and value is not None:
value = str(Decimal(str(value)).normalize())
elif field == 'worked_date' and value is not None:
value = str(value)
values[field] = value
encoded = json.dumps(values, sort_keys=True, default=str, ensure_ascii=False, separators=(',', ':'))
return hashlib.sha256(encoded.encode('utf-8')).hexdigest()

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();

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,7 @@ API endpoints for email viewing, classification, and rule management
import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from pydantic import BaseModel, Field
from datetime import datetime, date
import unicodedata
import re
@ -721,6 +721,80 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/{email_id}/sender-draft")
async def sender_creation_draft(email_id: int, use_ai: bool = False):
from app.services.email_sender_create import sender_suggestions, matching_companies
mail = execute_query_single("SELECT sender_name, sender_email, subject, body_text, body_html FROM email_messages WHERE id=%s AND deleted_at IS NULL", (email_id,))
if not mail:
raise HTTPException(404, 'Mailen blev ikke fundet')
draft = sender_suggestions(mail)
draft['ai_status'] = 'not_requested'
if use_ai:
import asyncio
from app.services.contact_mail_ai import extract_contact, extract_sender_company
results = await asyncio.gather(extract_contact(draft['parsed']), extract_sender_company(draft['parsed']), return_exceptions=True)
for field, result in zip(('contact', 'company'), results):
if isinstance(result, dict):
draft[field].update(result)
draft['ai_status'] = 'failed' if any(isinstance(result, Exception) for result in results) else 'completed'
# The actual sender address, rather than AI's interpretation, is the default contact email.
from email.utils import parseaddr
draft['contact']['email'] = parseaddr(str(mail.get('sender_email') or ''))[1].lower()
draft['companies'] = matching_companies(draft['company'], mail.get('sender_email'))
draft['contacts'] = execute_query("SELECT id, first_name, last_name, email FROM contacts WHERE LOWER(TRIM(email))=%s AND is_active=true ORDER BY id", (draft['contact'].get('email') or '',)) or []
draft.pop('parsed', None)
return draft
from app.services.email_sender_create import SenderCreateRequest
@router.post("/emails/{email_id}/create-sender")
async def create_sender_from_mail(email_id: int, payload: SenderCreateRequest, request: Request):
from app.services.email_sender_create import create_sender
return await create_sender(email_id, payload, getattr(request.state, 'user_id', None))
@router.get("/emails/purchase-options")
async def purchase_mail_options(q: str = Query('', max_length=200)):
like = '%' + q.strip() + '%'
return execute_query("""SELECT p.id, p.sag_id, p.description, p.quantity, p.external_ref,
p.status, s.titel AS case_title, c.name AS customer_name
FROM sag_salgsvarer p JOIN sag_sager s ON s.id = p.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE p.type = 'purchase' AND p.status <> 'cancelled' AND s.deleted_at IS NULL
AND (p.description ILIKE %s OR COALESCE(p.external_ref, '') ILIKE %s
OR s.titel ILIKE %s OR COALESCE(c.name, '') ILIKE %s OR s.id::text ILIKE %s)
ORDER BY p.updated_at DESC, p.id DESC LIMIT 15""", (like,) * 5) or []
class PurchaseMailLinkRequest(BaseModel):
purchase_id: int = Field(gt=0)
kind: str
tracking_number: Optional[str] = Field(default=None, max_length=100)
@router.post("/emails/{email_id}/purchase-link")
async def link_purchase_email(email_id: int, payload: PurchaseMailLinkRequest, request: Request):
from app.services.email_purchase_link import link_purchase_mail
return link_purchase_mail(email_id, payload.purchase_id, payload.kind,
payload.tracking_number, getattr(request.state, "user_id", None))
@router.get("/emails/{email_id}/sender-identity")
async def get_sender_identity(email_id: int):
"""Read-only, all exact sender candidates for the simplified inbox."""
from app.services.email_sender_identity import find_sender_candidates
row = execute_query_single(
"SELECT sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Email not found")
return find_sender_candidates(row.get("sender_email"), execute_query, IGNORED_SENDER_DOMAINS)
@router.get("/emails/{email_id}/domain-customer-suggestion")
async def get_domain_customer_suggestion(email_id: int):
"""Suggest customer based on sender domain for mails without known contact/customer."""
@ -967,6 +1041,7 @@ async def list_emails(
status: Optional[str] = Query(None),
classification: Optional[str] = Query(None),
folder: Optional[str] = Query(None),
unread: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(50, le=500),
offset: int = Query(0, ge=0)
@ -988,10 +1063,14 @@ async def list_emails(
where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)")
params.append(folder)
if unread is not None:
where_clauses.append("COALESCE(em.is_read, FALSE) = %s")
params.append(not unread)
if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s OR COALESCE(em.body_text, '') ILIKE %s)")
search_term = f"%{q}%"
params.extend([search_term, search_term, search_term])
params.extend([search_term, search_term, search_term, search_term])
where_sql = " AND ".join(where_clauses)
@ -1026,6 +1105,59 @@ async def list_emails(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/count")
async def count_emails(
status: Optional[str] = Query(None),
classification: Optional[str] = Query(None),
folder: Optional[str] = Query(None),
unread: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
):
"""Return the number of mails matching the list filters without changing list responses."""
try:
where_clauses = ["em.deleted_at IS NULL"]
params = []
if status:
where_clauses.append("em.status = %s")
params.append(status)
if classification:
where_clauses.append("em.classification = %s")
params.append(classification)
if folder:
where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)")
params.append(folder)
if unread is not None:
where_clauses.append("COALESCE(em.is_read, FALSE) = %s")
params.append(not unread)
if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s OR COALESCE(em.body_text, '') ILIKE %s)")
search_term = f"%{q}%"
params.extend([search_term, search_term, search_term, search_term])
row = execute_query_single(
f"SELECT COUNT(*) AS count FROM email_messages em WHERE {' AND '.join(where_clauses)}",
tuple(params),
) or {}
return {"count": int(row.get("count") or 0)}
except Exception as e:
logger.error(f"❌ Error counting emails: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/queue-counts")
async def get_email_queue_counts():
"""Counts for the mutually exclusive inbox status tabs."""
return execute_query_single(
"""SELECT
COUNT(*) FILTER (WHERE status = 'new') AS active,
COUNT(*) FILTER (WHERE COALESCE(is_read, FALSE) = FALSE) AS unread,
COUNT(*) FILTER (WHERE status = 'awaiting_user_action') AS awaiting_user_action,
COUNT(*) FILTER (WHERE status = 'processed') AS processed,
COUNT(*) AS all
FROM email_messages
WHERE deleted_at IS NULL"""
) or {'active': 0, 'unread': 0, 'awaiting_user_action': 0, 'processed': 0, 'all': 0}
@router.get("/emails/{email_id:int}", response_model=EmailDetail)
async def get_email(email_id: int, request: Request):
"""Get email detail by ID"""
@ -1243,7 +1375,7 @@ async def link_email(email_id: int, payload: Dict):
execute_update(query, tuple(params))
customer_id = payload.get('customer_id')
if customer_id:
if customer_id and payload.get('remember_domain', True):
email_row = execute_query_single(
"SELECT sender_email FROM email_messages WHERE id = %s",
(email_id,),
@ -1532,12 +1664,6 @@ async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest
"message": "Leverandørfaktura sendt til behandling",
}
if not customer_id and not supplier_id:
raise HTTPException(
status_code=409,
detail="Fastslå kunde eller leverandør, før mailen arkiveres",
)
execute_update(
"""
UPDATE email_messages
@ -1549,7 +1675,7 @@ async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest
return {
"success": True,
"action": action,
"message": "Mailen er arkiveret under den fastsatte kunde eller leverandør",
"message": "Mailen er arkiveret",
}

View File

@ -190,14 +190,14 @@
font-weight: 500;
}
.classification-invoice { background: #d4edda; color: #155724; }
.classification-order_confirmation { background: #d1ecf1; color: #0c5460; }
.classification-freight_note { background: #fff3cd; color: #856404; }
.classification-time_confirmation { background: #e2e3e5; color: #383d41; }
.classification-case_notification { background: #cce5ff; color: #004085; }
.classification-bankruptcy { background: #f8d7da; color: #721c24; }
.classification-invoice { background: var(--theme-success-bg, #d4edda); color: var(--theme-success-text, #155724); }
.classification-order_confirmation { background: var(--theme-info-bg, #d1ecf1); color: var(--theme-info-text, #0c5460); }
.classification-freight_note { background: var(--theme-warning-bg, #fff3cd); color: var(--theme-warning-text, #856404); }
.classification-time_confirmation { background: var(--theme-surface-soft, #e2e3e5); color: var(--theme-text, #383d41); }
.classification-case_notification { background: var(--theme-primary-bg, #cce5ff); color: var(--theme-primary-text, #004085); }
.classification-bankruptcy { background: var(--theme-danger-bg, #f8d7da); color: var(--theme-danger-text, #721c24); }
.classification-spam { background: #343a40; color: #fff; }
.classification-general { background: #e9ecef; color: #495057; }
.classification-general { background: var(--theme-surface-soft, #e9ecef); color: var(--theme-text, #495057); }
[data-bs-theme="dark"] .classification-badge {
opacity: 0.9;
@ -359,13 +359,13 @@
.email-actions .btn-danger-soft {
background: rgba(220, 53, 69, 0.08);
color: #a72a37;
color: var(--theme-danger-text, #a72a37);
border-color: rgba(220, 53, 69, 0.28);
}
.email-actions .btn-danger-soft:hover {
background: rgba(220, 53, 69, 0.14);
color: #8e1f2d;
color: var(--theme-danger-text, #8e1f2d);
}
.email-body {
@ -390,7 +390,7 @@
height: min(72vh, 980px);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
background: #ffffff;
background: var(--theme-surface, #ffffff);
display: block;
}
@ -813,7 +813,7 @@
/* Workflow Step Builder */
.workflow-step-item {
background: white;
background: var(--theme-surface, white);
border: 2px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 0.75rem;

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from app.core.database import execute_query
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
@ -127,6 +128,18 @@ async def reports_dashboard(request: Request):
SUM(monthly_hours * (hourly_rate - 300)) as estimated_profit
FROM customer_fixed_price_agreements
"""
if getattr(settings, 'WORKFLOWS_ENABLED', False):
available = execute_query("SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='customer_fixed_price_agreements' AND column_name='monthly_price'")
if available:
stats_query = """
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active_agreements,
COUNT(*) as total_agreements,
SUM(COALESCE(monthly_price, monthly_hours * hourly_rate)) as total_revenue,
SUM(COALESCE(monthly_price, monthly_hours * hourly_rate)
- monthly_hours * internal_cost_rate) as estimated_profit
FROM customer_fixed_price_agreements
"""
stats = execute_query(stats_query)
# Get performance data from view

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

@ -5,6 +5,7 @@ Creates ordre drafts and advances subscription periods
Runs daily at 04:00
"""
from app.services.invoice_fee_service import with_invoice_fee
import logging
from datetime import datetime, date
import json
@ -311,6 +312,7 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}"
)
ordre_lines = with_invoice_fee(ordre_lines, customer_id, cursor=cursor, native=True)
insert_query = """
INSERT INTO ordre_drafts (
title,

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
@ -39,7 +39,8 @@ class CustomerUpdate(BaseModel):
standard_hourly_rate: Optional[float] = None
special_freight_price: Optional[float] = None
supplier_service_enrolled: Optional[bool] = None
invoice_fee_amount: Optional[float] = None
invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False)
invoice_fee_exempt: Optional[bool] = None
class Customer(CustomerBase):
@ -268,6 +269,11 @@ class AnyDeskSession(BaseModel):
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)
@ -325,6 +331,8 @@ class TodoStep(TodoStepBase):
is_next: bool = False
created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None
assigned_to_user_id: Optional[int] = None
assigned_to_name: Optional[str] = None
created_at: datetime
completed_by_user_id: Optional[int] = None
completed_by_name: Optional[str] = None

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

@ -1,3 +1,4 @@
from app.services.invoice_fee_service import with_invoice_fee
import hashlib
import json
import logging
@ -16,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 (
@ -1703,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
@ -1718,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
@ -2019,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,
@ -2054,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}"
@ -2094,6 +2109,7 @@ class AlsoService:
)
draft_id = int(draft["id"])
else:
draft_lines = with_invoice_fee(draft_lines, customer_id)
draft = execute_query_single(
"""
INSERT INTO ordre_drafts (

View File

@ -318,7 +318,7 @@ def get_drift_status(limit: int = 5) -> Dict[str, Any]:
rows = execute_query(
f"""
SELECT device_name, message, source_event_id, raw_json
SELECT device_name, message, source_id, source_event_id, raw_json
FROM {rel_name}
WHERE (
COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') IN ('active', 'warning', 'critical', 'new')
@ -333,11 +333,39 @@ def get_drift_status(limit: int = 5) -> Dict[str, Any]:
blacklist = _get_drift_device_blacklist()
blacklist_set = set(blacklist)
ignore_rules: set[tuple[str, str]] = set()
ignore_rel = execute_query_single(
"SELECT to_regclass('public.drift_ignore_rules')::text AS rel"
) or {}
if ignore_rel.get("rel"):
rule_rows = execute_query(
"""
SELECT source_id, external_id
FROM drift_ignore_rules
WHERE is_active = TRUE
"""
) or []
ignore_rules = {
(
str(rule.get("source_id") or "").strip().lower(),
str(rule.get("external_id") or "").strip().lower(),
)
for rule in rule_rows
if rule.get("source_id") and rule.get("external_id")
}
filtered_rows: List[Dict[str, Any]] = []
for row in rows:
candidates = set(_event_blacklist_candidates(row))
if any(item in candidates for item in blacklist_set):
continue
source = str(row.get("source_id") or "").strip().lower()
external_id = str(row.get("source_event_id") or "").strip().lower()
for prefix in ("uisp-", "kuma-"):
if external_id.startswith(prefix):
external_id = external_id[len(prefix):]
break
if (source, external_id) in ignore_rules:
continue
filtered_rows.append(row)
top_items = filtered_rows[: max(1, int(limit or 5))]
@ -506,7 +534,7 @@ def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int - COALESCE(t.pause_total_seconds, 0)::int, 0) AS elapsed
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
@ -560,7 +588,7 @@ def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Di
t.start_tid,
t.slut_tid,
GREATEST(
EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int,
EXTRACT(EPOCH FROM (COALESCE(t.paused_at, NOW()) - COALESCE(t.start_tid, NOW())))::int,
0
) AS elapsed_seconds,
COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds
@ -1164,6 +1192,7 @@ def build_bottom_bar_state(
"mail": {
"unread": status.get("mails_unread", 0),
"customer_reply_needed": status.get("mails_unread", 0),
"route": "/emails?filter=unread",
},
"cases": {
"open": status.get("sager_open", 0),
@ -1171,16 +1200,18 @@ def build_bottom_bar_state(
},
"urgent": {
"count": status.get("sager_urgent", 0),
"route": "/sag?priority=urgent",
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
},
"unassigned": {
"count": status.get("sager_unassigned", 0),
"route": "/sag?unassigned=1",
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
},
"procurement": {
"to_order": int(procurement_attention.get("count") or 0),
"route": "/procurement",
"route": "/procurement?status=to_order",
},
"timer": {
"active_count": 1 if timer.get("active") else 0,
@ -1282,7 +1313,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 +1322,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()
@ -76,6 +76,7 @@ def _build_ical_response(events: list[dict], now: datetime) -> Response:
f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}",
*([f"DTEND:{_format_ical_dt(datetime.fromisoformat(event['end']))}"] if event.get("end") else []),
f"SUMMARY:{summary}",
f"DESCRIPTION:{description}",
"END:VEVENT",
@ -135,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")
@ -157,6 +160,10 @@ def _get_calendar_events(
"meeting",
"technician_visit",
"obs",
"quick_planned",
"quick_registered",
"planner_booking",
"absence",
}
requested_types = {
t.strip() for t in (types.split(",") if types else []) if t.strip()
@ -322,11 +329,88 @@ def _get_calendar_events(
)
)
quick_states = [state for state in ("planned", "registered")
if not normalized_types or f"quick_{state}" in normalized_types]
if quick_states:
query = """SELECT q.*, c.name AS customer_name, COALESCE(u.full_name,u.username) AS user_name
FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id
JOIN users u ON u.user_id=q.user_id
WHERE q.state=ANY(%s) AND q.starts_at<%s AND q.ends_at>%s"""
params = [quick_states, end_dt, start_dt]
if only_mine and resolved_user_id is not None:
query += " AND q.user_id=%s"
params.append(resolved_user_id)
if customer_id:
query += " AND q.customer_id=%s"
params.append(customer_id)
for row in execute_query(query, tuple(params)) or []:
kind = f"quick_{row['state']}"
label = "Planlagt tid" if row['state'] == 'planned' else "Brugt tid"
event = _build_event(kind, f"{label}: {row['description']} · {row['user_name']}",
row['starts_at'],
f"/sag/new?quick_plan={row['id']}" if row['state'] == 'planned' else '/timetracking',
{"reference_id": row['id'], "reference_type": "quick_time",
"event_kind": kind, "user_id": row['user_id'],
"customer_id": row.get('customer_id'), "customer_name": row.get('customer_name')})
event['end'] = row['ends_at'].isoformat()
event['backgroundColor'] = '#336699' if row['state'] == 'planned' else '#7c3aed'
event['borderColor'] = event['backgroundColor']
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,
@ -386,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)
@ -435,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

@ -161,7 +161,7 @@
border: 1px solid var(--calendar-border);
padding: 0.5rem 0.75rem;
width: 100%;
background: #ffffff;
background: var(--theme-surface, #ffffff);
color: var(--calendar-ink);
}
@ -175,7 +175,7 @@
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #ffffff;
background: var(--theme-surface, #ffffff);
border: 1px solid var(--calendar-border);
border-radius: 8px;
padding: 0.35rem 0.6rem;
@ -233,7 +233,7 @@
top: calc(100% + 6px);
left: 0;
right: 0;
background: #ffffff;
background: var(--theme-surface, #ffffff);
border: 1px solid var(--calendar-border);
border-radius: 12px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
@ -378,7 +378,7 @@
.event-meeting {
background: rgba(15, 76, 117, 0.18);
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
}
.event-technician_visit {
@ -459,6 +459,8 @@
<div class="filter-block">
<label>Event typer</label>
<div class="type-tags">
<label class="type-tag"><input type="checkbox" class="type-filter" value="quick_planned" checked><span class="type-dot" style="background:#336699"></span>Planlagt tid</label>
<label class="type-tag"><input type="checkbox" class="type-filter" value="quick_registered" checked><span class="type-dot" style="background:#7c3aed"></span>Brugt tid</label>
<label class="type-tag" title="Vis deadlines">
<input type="checkbox" class="type-filter" value="deadline" checked>
<span class="type-dot" style="background: var(--calendar-ember);"></span>
@ -510,7 +512,7 @@
<div class="status-bar" id="rangeLabel">Indlæser periode...</div>
</div>
<div id="calendar"></div>
<div class="calendar-legend">
<div class="calendar-legend"><div class="legend-item"><span class="legend-dot" style="background:#336699"></span>Planlagt tid</div><div class="legend-item"><span class="legend-dot" style="background:#7c3aed"></span>Brugt tid</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div>

View File

@ -38,6 +38,7 @@ class DriftCustomerMappingPayload(BaseModel):
class DriftBlacklistPayload(BaseModel):
value: Optional[str] = None
reason: Optional[str] = None
def run_uptime_kuma_sync() -> Dict[str, Any]:
@ -186,12 +187,12 @@ def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
or nested_overview.get("last_seen")
or updated
)
duration_minutes = row.get("duration_minutes")
if duration_minutes is None:
started_dt = _coerce_dt(started)
end_dt = _coerce_dt(resolved) or _coerce_dt(updated) or datetime.now(timezone.utc)
if started_dt:
duration_minutes = max(int((end_dt - started_dt).total_seconds() // 60), 0)
duration_minutes = int(row.get("duration_minutes") or 0)
started_dt = _coerce_dt(started)
end_dt = _coerce_dt(resolved) or datetime.now(timezone.utc)
if started_dt:
calculated_duration = max(int((end_dt - started_dt).total_seconds() // 60), 0)
duration_minutes = max(duration_minutes, calculated_duration)
source_event_id = row.get("source_event_id") or ""
monitor_key = source_event_id[5:] if source_event_id.startswith("kuma-") else source_event_id
@ -400,6 +401,22 @@ def _ensure_schema() -> None:
)
"""
)
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_ignore_rules (
id SERIAL PRIMARY KEY,
source_id INTEGER REFERENCES drift_sources(id) ON DELETE CASCADE,
external_id VARCHAR(255) NOT NULL,
device_name VARCHAR(255),
reason VARCHAR(100),
ignored_by INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_id, external_id)
)
"""
)
def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
@ -546,19 +563,52 @@ def _event_is_blacklisted(payload: Dict[str, Any], blacklist: List[str]) -> bool
def _filter_out_blacklisted_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
blacklist = _get_drift_device_blacklist()
if not blacklist:
ignore_rows = execute_query(
"""SELECT source_id, LOWER(external_id) AS external_id
FROM drift_ignore_rules WHERE is_active = TRUE"""
) or []
ignore_keys = {
(int(rule.get("source_id") or 0), str(rule.get("external_id") or "").strip().lower())
for rule in ignore_rows
if rule.get("external_id")
}
if not blacklist and not ignore_keys:
return rows
filtered: List[Dict[str, Any]] = []
blacklist_set = set(blacklist)
for row in rows:
candidates = set(_event_blacklist_candidates(row))
source_id = int(row.get("source_id") or 0)
if any((source_id, item) in ignore_keys for item in candidates):
continue
if any(item in candidates for item in blacklist_set):
continue
filtered.append(row)
return filtered
def _event_matches_ignore_rule(payload: Dict[str, Any], source_id: int, rules: Set[tuple[int, str]]) -> bool:
candidates = set(_event_blacklist_candidates({
"source_event_id": payload.get("source_event_id"),
"device_name": payload.get("device"),
"raw_json": payload.get("raw_json") or {},
}))
return any((int(source_id or 0), candidate) in rules for candidate in candidates)
def _active_ignore_rule_keys() -> Set[tuple[int, str]]:
rows = execute_query(
"""SELECT source_id, LOWER(external_id) AS external_id
FROM drift_ignore_rules WHERE is_active = TRUE"""
) or []
return {
(int(row.get("source_id") or 0), str(row.get("external_id") or "").strip().lower())
for row in rows
if row.get("external_id")
}
def _is_uptime_kuma_configured() -> bool:
cfg = _get_drift_connector_config()
return bool(cfg.get("base_url") and cfg.get("api_key"))
@ -1357,7 +1407,12 @@ def _run_uptime_kuma_sync_internal() -> Dict[str, Any]:
}
blacklist = _get_drift_device_blacklist()
filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)]
ignore_rules = _active_ignore_rule_keys()
filtered_events = [
event for event in events
if not _event_is_blacklisted(event, blacklist)
and not _event_matches_ignore_rule(event, int(source.get("id") or 0), ignore_rules)
]
skipped = len(events) - len(filtered_events)
for item in filtered_events:
@ -1436,7 +1491,12 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
}
blacklist = _get_drift_device_blacklist()
filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)]
ignore_rules = _active_ignore_rule_keys()
filtered_events = [
event for event in events
if not _event_is_blacklisted(event, blacklist)
and not _event_matches_ignore_rule(event, int(source.get("id") or 0), ignore_rules)
]
skipped = len(events) - len(filtered_events)
for item in filtered_events:
@ -1521,11 +1581,10 @@ async def get_drift_summary():
active_alerts += 1
if row.get("ticket_id") is None:
unassigned += 1
if severity_value == "critical":
critical += 1
if severity_value == "warning":
warnings += 1
if severity_value == "critical":
critical += 1
if severity_value == "warning":
warnings += 1
resolved_at = _coerce_dt(row.get("resolved_at"))
if resolved_at and resolved_at.date() == today:
@ -1587,8 +1646,11 @@ async def list_events(
params: List[Any] = []
filters = []
if status:
filters.append("e.status = %s")
params.append(status)
if str(status).strip().lower() == "active":
filters.append("LOWER(COALESCE(e.status, '')) IN ('active', 'warning', 'critical', 'new')")
else:
filters.append("e.status = %s")
params.append(status)
if severity:
filters.append("e.severity = %s")
params.append(severity)
@ -1931,23 +1993,80 @@ async def acknowledge_event(event_id: int):
return {"id": event_id, "status": "acknowledged"}
@router.get("/drift/ignore-rules")
async def list_ignore_rules():
_ensure_schema()
return execute_query(
"""SELECT r.id, r.source_id, s.name AS source_name, r.external_id,
r.device_name, r.reason, r.ignored_by,
COALESCE(NULLIF(u.full_name, ''), u.username) AS ignored_by_name,
r.created_at, r.updated_at
FROM drift_ignore_rules r
LEFT JOIN drift_sources s ON s.id = r.source_id
LEFT JOIN users u ON u.user_id = r.ignored_by
WHERE r.is_active = TRUE
ORDER BY r.created_at DESC, r.id DESC"""
) or []
@router.delete("/drift/ignore-rules/{rule_id:int}")
async def reactivate_ignored_device(rule_id: int):
_ensure_schema()
rule = execute_query_single(
"SELECT id FROM drift_ignore_rules WHERE id = %s AND is_active = TRUE",
(rule_id,),
)
if not rule:
raise HTTPException(status_code=404, detail="Ignore rule not found")
execute_query(
"""UPDATE drift_ignore_rules
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(rule_id,),
)
return {"id": rule_id, "is_active": False}
@router.post("/drift/events/{event_id:int}/blacklist")
async def blacklist_event(event_id: int, payload: Optional[DriftBlacklistPayload] = None):
async def blacklist_event(event_id: int, request: Request, payload: Optional[DriftBlacklistPayload] = None):
_ensure_schema()
event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,))
if not event:
raise HTTPException(status_code=404, detail="Event not found")
current = _get_drift_device_blacklist()
explicit = str((payload.value if payload else "") or "").strip().lower()
to_add = [explicit] if explicit else _event_blacklist_candidates(event)
source_event_id = str(event.get("source_event_id") or "").strip().lower()
external_id = explicit or source_event_id
if external_id.startswith("uisp-"):
external_id = external_id[5:]
elif external_id.startswith("kuma-"):
external_id = external_id[5:]
if not external_id:
raise HTTPException(status_code=400, detail="Enheden mangler et stabilt kilde-ID")
merged = list(current)
for item in to_add:
if item and item not in merged:
merged.append(item)
_save_drift_device_blacklist(merged)
reason = str((payload.reason if payload else "") or "").strip().lower() or "other"
allowed_reasons = {"test", "retired", "unmanaged", "noisy", "other"}
if reason not in allowed_reasons:
raise HTTPException(status_code=400, detail="Ukendt årsag til ignorering")
ignored_by = getattr(request.state, "user_id", None)
execute_query(
"""INSERT INTO drift_ignore_rules
(source_id, external_id, device_name, reason, ignored_by, is_active)
VALUES (%s, %s, %s, %s, %s, TRUE)
ON CONFLICT (source_id, external_id)
DO UPDATE SET device_name = EXCLUDED.device_name,
reason = EXCLUDED.reason,
ignored_by = EXCLUDED.ignored_by,
is_active = TRUE,
updated_at = CURRENT_TIMESTAMP""",
(
event.get("source_id"),
external_id,
event.get("device_name"),
reason,
ignored_by,
),
)
current_status = str(event.get("status") or "").strip().lower()
auto_acknowledged = False
@ -1972,8 +2091,8 @@ async def blacklist_event(event_id: int, payload: Optional[DriftBlacklistPayload
return {
"id": event_id,
"blacklisted": to_add,
"blacklist_size": len(merged),
"blacklisted": [external_id],
"reason": reason,
"status": "acknowledged" if auto_acknowledged else current_status,
"auto_acknowledged": auto_acknowledged,
}

View File

@ -49,8 +49,11 @@
</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<details class="card shadow-sm border-0 mb-4">
<summary class="card-header bg-body border-0 py-3 px-4 fw-semibold" style="cursor:pointer">
<i class="bi bi-gear me-2"></i>Opsætning, kundemapping og ignorerede enheder
</summary>
<div class="card-body border-top">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Bulk Mapping</h5>
@ -76,8 +79,22 @@
</tbody>
</table>
</div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h5 class="mb-1">Ignorerede enheder</h5>
<div class="small text-muted">Enhederne ignoreres ved alle fremtidige sync. De kan altid sættes under overvågning igen.</div>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadIgnoreRules()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Enhed</th><th>Kilde</th><th>Årsag</th><th>Ignoreret af</th><th>Dato</th><th></th></tr></thead>
<tbody id="drift-ignore-rules-body"><tr><td colspan="6" class="text-muted">Indlæser...</td></tr></tbody>
</table>
</div>
</div>
</div>
</details>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
@ -97,13 +114,13 @@
<div class="d-flex flex-wrap gap-2 mb-3">
<select class="form-select form-select-sm w-auto" id="drift-status-filter">
<option value="">Status: Alle</option>
<option value="active">Aktiv</option>
<option value="active" selected>Kræver handling</option>
<option value="acknowledged">Godkendt</option>
<option value="resolved">Løst</option>
</select>
<select class="form-select form-select-sm w-auto" id="drift-severity-filter">
<option value="">Severity: Alle</option>
<option value="critical" selected>Critical</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
@ -112,9 +129,17 @@
<option value="uptime kuma">Uptime Kuma</option>
<option value="uisp">UISP</option>
</select>
<select class="form-select form-select-sm w-auto" id="drift-attention-filter">
<option value="">Alle alarmer</option>
<option value="no_case">Uden sag</option>
<option value="unknown_customer">Ukendt kunde</option>
<option value="missing_ip">Mangler IP</option>
</select>
<input class="form-control form-control-sm" style="max-width:240px" id="drift-search-filter" placeholder="Søg kunde, device, IP…">
<button class="btn btn-sm btn-outline-primary" onclick="loadDriftEvents()">
<i class="bi bi-funnel me-1"></i>Opdater filtre
</button>
<span class="small text-muted align-self-center ms-auto" id="drift-result-count"></span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
@ -149,6 +174,34 @@
</div>
</div>
<div class="modal fade" id="driftIgnoreModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ignorér enhed fremover</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<p class="mb-3" id="driftIgnoreDeviceText"></p>
<input type="hidden" id="driftIgnoreEventId">
<label class="form-label" for="driftIgnoreReason">Årsag</label>
<select class="form-select" id="driftIgnoreReason">
<option value="test">Testudstyr</option>
<option value="retired">Nedlagt udstyr</option>
<option value="unmanaged">Ikke administreret af BMC</option>
<option value="noisy">Støjende/irrelevant alarm</option>
<option value="other">Andet</option>
</select>
<div class="form-text">Reglen bindes til enhedens stabile ID hos kilden, også hvis navnet senere ændres.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-danger" onclick="confirmIgnoreDevice()">Ignorér enhed fremover</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="driftMapModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@ -176,6 +229,24 @@
let driftCustomersCache = [];
let driftBlacklistCache = [];
function escapeDriftHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
function driftReasonLabel(value) {
return ({ test: 'Testudstyr', retired: 'Nedlagt udstyr', unmanaged: 'Ikke administreret', noisy: 'Støjende alarm', other: 'Andet' })[value] || 'Andet';
}
function formatDriftDuration(totalMinutes) {
const minutes = Math.max(0, Number(totalMinutes || 0));
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours} t ${minutes % 60} min`;
return `${Math.floor(hours / 24)} d ${hours % 24} t`;
}
function parseDriftBlacklistValue(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) return [];
@ -244,7 +315,7 @@ function isDriftEventBlacklisted(event, blacklist) {
function syncBottomDriftCountFromVisibleEvents(events) {
const activeStatuses = new Set(['active', 'warning', 'critical', 'new']);
const activeCount = (Array.isArray(events) ? events : []).filter(event => {
const activeCount = typeof events === 'number' ? Math.max(0, events) : (Array.isArray(events) ? events : []).filter(event => {
const status = String((event && event.status) || '').trim().toLowerCase();
return activeStatuses.has(status);
}).length;
@ -291,41 +362,21 @@ async function loadDriftSummary() {
const res = await fetch('/api/v1/drift/summary', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const blacklist = await loadDriftBlacklist();
const fetchVisibleEvents = async (params) => {
const query = new URLSearchParams(params || {});
query.set('limit', '200');
const eventsRes = await fetch(`/api/v1/drift/events?${query.toString()}`, { credentials: 'include' });
if (!eventsRes.ok) return [];
const payload = await eventsRes.json();
const events = Array.isArray(payload) ? payload : [];
return events.filter(event => !isDriftEventBlacklisted(event, blacklist));
};
const [activeEvents, uispEvents] = await Promise.all([
fetchVisibleEvents({ status: 'active' }),
fetchVisibleEvents({ source: 'uisp' }),
]);
const criticalCount = activeEvents.filter(event => String(event?.severity || '').toLowerCase() === 'critical').length;
const warningCount = activeEvents.filter(event => String(event?.severity || '').toLowerCase() === 'warning').length;
const uispActiveCount = uispEvents.filter(event => String(event?.status || '').toLowerCase() === 'active').length;
document.getElementById('drift-active-alerts').textContent = activeEvents.length;
document.getElementById('drift-critical').textContent = criticalCount;
document.getElementById('drift-warnings').textContent = warningCount;
document.getElementById('drift-active-alerts').textContent = data.active_alerts ?? 0;
document.getElementById('drift-critical').textContent = data.critical ?? 0;
document.getElementById('drift-warnings').textContent = data.warnings ?? 0;
document.getElementById('drift-resolved-today').textContent = data.resolved_today ?? 0;
const uisp = data.uisp || {};
const configured = !!uisp.configured;
const configuredEl = document.getElementById('drift-uisp-configured');
configuredEl.textContent = configured ? 'Konfigureret' : 'Ikke konfigureret';
configuredEl.className = configured ? 'badge text-bg-success' : 'badge text-bg-secondary';
document.getElementById('drift-uisp-active').textContent = uispActiveCount;
document.getElementById('drift-uisp-total').textContent = uispEvents.length;
document.getElementById('drift-uisp-active').textContent = uisp.active_events ?? 0;
document.getElementById('drift-uisp-total').textContent = uisp.total_events ?? 0;
document.getElementById('drift-uisp-last-sync').textContent = uisp.last_sync
? new Date(uisp.last_sync).toLocaleString('da-DK')
: '-';
syncBottomDriftCountFromVisibleEvents(Number(data.active_alerts || 0));
} catch (e) {
console.error(e);
document.getElementById('drift-active-alerts').textContent = '—';
@ -353,54 +404,88 @@ async function loadDriftEvents() {
const res = await fetch(`/api/v1/drift/events?${params.toString()}`, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const events = await res.json();
const blacklist = await loadDriftBlacklist();
const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
syncBottomDriftCountFromVisibleEvents(visibleEvents);
const attention = document.getElementById('drift-attention-filter').value;
const search = document.getElementById('drift-search-filter').value.trim().toLowerCase();
const visibleEvents = (Array.isArray(events) ? events : [])
.filter(event => {
if (attention === 'no_case' && event.ticket_id) return false;
if (attention === 'unknown_customer' && event.customer && event.customer !== 'UISP' && event.customer !== 'Uptime Kuma') return false;
if (attention === 'missing_ip' && event.ip) return false;
if (!search) return true;
return [event.customer, event.device, event.ip, event.site_client_name, event.source]
.some(value => String(value || '').toLowerCase().includes(search));
})
.sort((a, b) => {
const severity = { critical: 0, warning: 1, info: 2 };
const customerA = (!a.customer || a.customer === 'UISP' || a.customer === 'Uptime Kuma') ? '' : a.customer;
const customerB = (!b.customer || b.customer === 'UISP' || b.customer === 'Uptime Kuma') ? '' : b.customer;
const groupA = String(customerA || a.site_client_name || 'Ukendt kunde');
const groupB = String(customerB || b.site_client_name || 'Ukendt kunde');
return groupA.localeCompare(groupB, 'da')
|| (severity[String(a.severity || '').toLowerCase()] ?? 3) - (severity[String(b.severity || '').toLowerCase()] ?? 3)
|| Number(b.duration_minutes || 0) - Number(a.duration_minutes || 0);
});
document.getElementById('drift-result-count').textContent = `${visibleEvents.length} viste alarmer`;
if (!visibleEvents.length) {
body.innerHTML = '<tr><td colspan="11" class="text-muted">Ingen hændelser fundet.</td></tr>';
return;
}
body.innerHTML = visibleEvents.map(event => `
let previousGroup = '';
body.innerHTML = visibleEvents.map(event => {
const realCustomer = (!event.customer || event.customer === 'UISP' || event.customer === 'Uptime Kuma') ? '' : event.customer;
const group = realCustomer || event.site_client_name || 'Ukendt kunde';
const groupHeader = group !== previousGroup
? `<tr class="table-light"><td colspan="11" class="fw-semibold"><i class="bi bi-building me-2"></i>${escapeDriftHtml(group)}</td></tr>`
: '';
previousGroup = group;
const quality = [
(!event.customer || event.customer === 'UISP' || event.customer === 'Uptime Kuma') ? '<span class="badge text-bg-warning">Ukendt kunde</span>' : '',
!event.ip ? '<span class="badge text-bg-secondary">Mangler IP</span>' : '',
].filter(Boolean).join(' ');
return groupHeader + `
<tr>
<td><span class="badge bg-${event.status === 'active' ? 'danger' : event.status === 'acknowledged' ? 'warning' : 'success'}">${event.status || 'new'}</span></td>
<td><span class="badge bg-${event.severity === 'critical' ? 'danger' : event.severity === 'warning' ? 'warning' : 'secondary'}">${event.severity || 'info'}</span></td>
<td><span class="badge bg-${event.status === 'active' ? 'danger' : event.status === 'acknowledged' ? 'warning' : 'success'}">${event.status === 'active' ? 'Kræver handling' : event.status === 'acknowledged' ? 'Set' : event.status === 'resolved' ? 'Løst' : escapeDriftHtml(event.status || 'Ny')}</span></td>
<td><span class="badge bg-${event.severity === 'critical' ? 'danger' : event.severity === 'warning' ? 'warning' : 'secondary'}">${escapeDriftHtml(event.severity || 'info')}</span></td>
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.source || 'Ukendt'}</span>
<span>${escapeDriftHtml(event.source || 'Ukendt')}</span>
${event.source_link ? `<a class="btn btn-sm btn-outline-secondary" href="${event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn kilde"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.customer || '-'}</span>
<span>${escapeDriftHtml(event.customer || '-')}</span>
${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})' title="Knyt monitor til kunde">Knyt</button>` : ''}
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.device || '-'}</span>
<span>${escapeDriftHtml(event.device || '-')}</span>
${(event.device_link || event.source_link) ? `<a class="btn btn-sm btn-outline-secondary" href="${event.device_link || event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn enhed"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div>
</td>
<td>${event.ip || '-'}</td>
<td>${event.site_client_name || event.site || event.customer || '-'}</td>
<td>${escapeDriftHtml(event.ip || '-')}<div class="mt-1">${quality}</div></td>
<td>${escapeDriftHtml(event.site_client_name || event.site || event.customer || '-')}</td>
<td>${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}</td>
<td>${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}</td>
<td>${event.duration_minutes !== null && event.duration_minutes !== undefined ? `${event.duration_minutes} min` : '-'}</td>
<td>${formatDriftDuration(event.duration_minutes)}</td>
<td>
<div class="d-flex align-items-center gap-2">
${event.status === 'active' ? `<button class="btn btn-sm btn-outline-success" onclick="acknowledgeDriftEvent(${event.id})">Godkend</button>` : ''}
<button class="btn btn-sm btn-outline-danger" onclick="blacklistDriftEvent(${event.id})" title="Blacklist device fremadrettet">
Blacklist
</button>
${event.ticket_id ? `<a href="/sag/${event.ticket_id}/v3" class="btn btn-sm btn-outline-secondary">Sag #${event.ticket_id}</a>` : '<button class="btn btn-sm btn-outline-primary" onclick="createDriftCase(' + event.id + ')">Opret sag</button>'}
<div class="d-flex align-items-center gap-1 flex-nowrap">
${event.ticket_id ? `<a href="/sag/${Number(event.ticket_id)}/v3" class="btn btn-sm btn-primary">Åbn sag</a>` : `<button class="btn btn-sm btn-primary" onclick="createDriftCase(${Number(event.id)})">Opret sag</button>`}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-label="Flere handlinger">Mere</button>
<ul class="dropdown-menu dropdown-menu-end">
${event.status === 'active' ? `<li><button class="dropdown-item" onclick="acknowledgeDriftEvent(${Number(event.id)})"><i class="bi bi-check2 me-2"></i>Markér som set</button></li>` : ''}
${event.monitor_key ? `<li><button class="dropdown-item" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})'><i class="bi bi-link-45deg me-2"></i>Knyt kunde</button></li>` : ''}
<li><button class="dropdown-item text-danger" onclick='openIgnoreDeviceModal(${Number(event.id)}, ${JSON.stringify(event.device || 'Ukendt enhed')}, ${JSON.stringify(event.source || 'Ukendt kilde')})'><i class="bi bi-eye-slash me-2"></i>Ignorér enhed fremover</button></li>
</ul>
</div>
</div>
</td>
</tr>
`).join('');
`}).join('');
} catch (e) {
console.error(e);
syncBottomDriftCountFromVisibleEvents([]);
body.innerHTML = '<tr><td colspan="11" class="text-muted">Kunne ikke hente drift-data.</td></tr>';
}
}
@ -437,27 +522,67 @@ async function acknowledgeDriftEvent(eventId) {
await loadDriftEvents();
}
async function blacklistDriftEvent(eventId, deviceName = '') {
const label = (deviceName || '').trim() || `event #${eventId}`;
if (!confirm(`Blacklist ${label} for fremtidige Drift sync?`)) {
return;
}
function openIgnoreDeviceModal(eventId, deviceName, sourceName) {
document.getElementById('driftIgnoreEventId').value = Number(eventId || 0);
document.getElementById('driftIgnoreDeviceText').textContent = `${deviceName || 'Ukendt enhed'} fra ${sourceName || 'ukendt kilde'} fjernes fra nuværende og fremtidige Drift-alarmer.`;
document.getElementById('driftIgnoreReason').value = 'noisy';
bootstrap.Modal.getOrCreateInstance(document.getElementById('driftIgnoreModal')).show();
}
async function confirmIgnoreDevice() {
const eventId = Number(document.getElementById('driftIgnoreEventId').value || 0);
const reason = document.getElementById('driftIgnoreReason').value || 'other';
if (!eventId) return;
const res = await fetch(`/api/v1/drift/events/${eventId}/blacklist`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(deviceName || '').trim().toLowerCase() })
body: JSON.stringify({ reason })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke blacklist device');
alert(data.detail || 'Kunne ikke ignorere enheden');
return;
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('driftIgnoreModal')).hide();
await loadDriftSummary();
await loadDriftEvents();
showNotification('Device blacklistet for fremtidige sync', 'success');
await loadIgnoreRules();
showNotification('Enheden ignoreres ved fremtidige sync', 'success');
}
async function loadIgnoreRules() {
const body = document.getElementById('drift-ignore-rules-body');
if (!body) return;
try {
const res = await fetch('/api/v1/drift/ignore-rules', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rules = await res.json();
body.innerHTML = (Array.isArray(rules) ? rules : []).map(rule => `
<tr>
<td><strong>${escapeDriftHtml(rule.device_name || rule.external_id)}</strong><div class="small text-muted">${escapeDriftHtml(rule.external_id)}</div></td>
<td>${escapeDriftHtml(rule.source_name || '-')}</td>
<td>${escapeDriftHtml(driftReasonLabel(rule.reason))}</td>
<td>${escapeDriftHtml(rule.ignored_by_name || 'Ukendt')}</td>
<td>${rule.created_at ? new Date(rule.created_at).toLocaleString('da-DK') : '-'}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-success" onclick="reactivateIgnoredDevice(${Number(rule.id)})">Overvåg igen</button></td>
</tr>`).join('') || '<tr><td colspan="6" class="text-success">Ingen enheder ignoreres.</td></tr>';
} catch (e) {
body.innerHTML = '<tr><td colspan="6" class="text-danger">Kunne ikke hente ignorerede enheder.</td></tr>';
}
}
async function reactivateIgnoredDevice(ruleId) {
const res = await fetch(`/api/v1/drift/ignore-rules/${Number(ruleId)}`, { method: 'DELETE', credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke sætte enheden under overvågning igen');
return;
}
await loadIgnoreRules();
await loadDriftSummary();
await loadDriftEvents();
showNotification('Enheden overvåges igen fra næste sync', 'success');
}
async function mapDriftCustomer(monitorKey, monitorName) {
@ -616,6 +741,10 @@ window.addEventListener('DOMContentLoaded', () => {
loadDriftSummary();
loadDriftEvents();
loadDriftHistory();
loadIgnoreRules();
document.getElementById('drift-search-filter')?.addEventListener('keydown', event => {
if (event.key === 'Enter') loadDriftEvents();
});
});
</script>
{% endblock %}

View File

@ -89,7 +89,7 @@
.fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; }
.fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; }
.fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; }
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; }
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: var(--theme-success-text, #146c43); }
.fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; }
.fedex-row-title {

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
})
@ -438,6 +445,27 @@ async def hardware_eset_import(request: Request):
})
@router.get("/hardware/{hardware_id:int}/label", response_class=HTMLResponse)
async def hardware_label(request: Request, hardware_id: int,
size: str = Query("62x40"), base_url: Optional[str] = Query(None, max_length=300)):
from app.core.config import settings
from app.modules.hardware.labels import LABEL_SIZES, hardware_label_url, hardware_label_qr
if size not in LABEL_SIZES:
raise HTTPException(400, detail="Vælg en af de understøttede labelstørrelser")
rows = execute_query("SELECT id, brand, model, serial_number FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,))
if not rows:
raise HTTPException(404, detail="Hardware not found")
label_base = (base_url or settings.HUB_BASE_URL).strip().rstrip('/')
target_url = hardware_label_url(label_base, hardware_id)
width, height = LABEL_SIZES[size]
return templates.TemplateResponse("modules/hardware/templates/label.html", {
"request": request, "hardware": rows[0], "size": size,
"width": width, "height": height, "base_url": label_base,
"target_url": target_url, "qr_svg": hardware_label_qr(target_url),
}, headers={"Cache-Control": "no-store"})
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details."""
@ -448,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 = []
@ -624,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

@ -0,0 +1,26 @@
"""Local QR generation for printable inventory labels. No asset writes."""
from urllib.parse import urlsplit, urlunsplit
import qrcode
from qrcode.image.svg import SvgPathImage
from fastapi import HTTPException
LABEL_SIZES = {'62x40': (62, 40), '62x29': (62, 29), '89x36': (89, 36)}
def hardware_label_url(base_url: str, hardware_id: int) -> str:
try:
parts = urlsplit(base_url.strip())
except ValueError as error:
raise HTTPException(400, "Hub-adressen er ugyldig") from error
if (parts.scheme not in {'http', 'https'} or not parts.hostname
or parts.username or parts.password or parts.query or parts.fragment):
raise HTTPException(400, 'Brug en http- eller https-adresse uden login, søgeparametre eller fragment.')
return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip('/') + f'/hardware/{hardware_id}', '', ''))
def hardware_label_qr(url: str) -> str:
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
qr.add_data(url)
qr.make(fit=True)
return qr.make_image(image_factory=SvgPathImage).to_string().decode('utf-8')

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

@ -27,7 +27,7 @@
}
.action-card {
background: white;
background: var(--theme-surface, white);
padding: 1rem;
border-radius: 8px;
text-align: center;
@ -56,7 +56,7 @@
.switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; }
.switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; }
.switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: #f4f6f8; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
.switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: var(--theme-surface-soft, #f4f6f8); color: var(--theme-text, #263645); font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
.switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); }
.switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; }
.switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; }
@ -79,7 +79,7 @@
top: 0;
bottom: 0;
width: 2px;
background: #e9ecef;
background: var(--theme-surface-soft, #e9ecef);
}
.timeline-item {
position: relative;
@ -92,7 +92,7 @@
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
background: white;
background: var(--theme-surface, white);
border: 2px solid var(--accent);
}
.timeline-item.active .timeline-marker {
@ -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 %}
@ -259,6 +268,7 @@
<!-- Link to Edit -->
<div class="ms-auto d-flex gap-2">
<a href="/hardware/{{ hardware.id }}/label" class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" title="Print label med QR-kode"><i class="bi bi-qr-code me-1"></i>Label</a>
<button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET">
<i class="bi bi-arrow-repeat"></i>
</button>
@ -273,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">
@ -351,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>
@ -608,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>
@ -708,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">
@ -772,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>
@ -870,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>
@ -1086,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">
@ -1116,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"
@ -1127,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"
@ -1138,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>
@ -1230,7 +1290,7 @@
<label class="form-label fw-bold">Vælg ny lokation</label>
<input type="text" class="form-control mb-2" id="locationSearchInput" placeholder="🔍 Søg efter lokation..." autocomplete="off">
<div class="list-group border rounded" id="locationList" style="max-height: 350px; overflow-y: auto; background: #fff;">
<div class="list-group border rounded" id="locationList" style="max-height: 350px; overflow-y: auto; background: var(--theme-surface, #fff);">
{% macro render_location_option(node, depth) %}
<div class="location-item-container" data-location-name="{{ node.name | lower }}">
<label class="list-group-item list-group-item-action cursor-pointer border-0 py-1 px-2" style="padding-left: {{ depth * 20 + 10 }}px !important;">
@ -1349,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 {
@ -1488,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);
@ -1496,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.');
@ -1523,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
})
});
@ -1568,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();
@ -1583,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') {
@ -1595,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.`;
@ -1726,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>
@ -394,6 +408,7 @@
<div class="hardware-actions" onclick="event.stopPropagation()">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
<a href="/hardware/{{ item.id }}/label" class="btn-action" target="_blank" rel="noopener"><i class="bi bi-qr-code"></i> Label</a>
</div>
</div>
</div>
@ -407,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>
@ -421,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>
@ -447,6 +464,7 @@
<td class="text-end">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
<a href="/hardware/{{ item.id }}/label" class="btn-action" target="_blank" rel="noopener"><i class="bi bi-qr-code"></i> Label</a>
</td>
</tr>
{% endfor %}
@ -468,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

@ -0,0 +1,34 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Label · BMC-{{ '%05d'|format(hardware.id) }}</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#e8eef4;color:#243449;font-family:Arial,Helvetica,sans-serif}
.controls{max-width:760px;margin:32px auto 22px;padding:22px;background:#fff;border:1px solid #bdcbd5;border-radius:12px}
h1{font-size:21px;margin:0 0 7px}p{font-size:13px;color:#65758a;line-height:1.5}form{display:grid;grid-template-columns:150px 1fr;gap:12px}label{font-size:12px;font-weight:600;display:block;margin-bottom:5px}input,select{width:100%;padding:9px;border:1px solid #bdcbd5;border-radius:7px;background:#fff;color:#243449}button,.back{display:inline-block;padding:10px 14px;border-radius:7px;border:1px solid #336699;font-size:13px;cursor:pointer;text-decoration:none}button{background:#336699;color:#fff}.back{background:#fff;color:#336699}.buttons{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap}.target{overflow-wrap:anywhere}.preview{display:flex;justify-content:center;padding:10px 12px 40px}
.asset-label{width:{{ width }}mm;height:{{ height }}mm;display:grid;grid-template-columns:minmax(0,1fr) {{ 21 if height == 29 else 27 }}mm;gap:1.5mm;padding:2.5mm;background:#fff;color:#111;box-shadow:0 8px 30px #23374d26;outline:1px solid #bdcbd5;overflow:hidden}
.label-copy{display:flex;flex-direction:column;min-width:0;justify-content:space-between}.brand{font-size:7pt;font-weight:800;letter-spacing:.3pt;border-bottom:.35mm solid #111;padding-bottom:1mm}.name{font-size:{{ 8 if height == 29 else 10 }}pt;font-weight:700;line-height:1.15;overflow-wrap:anywhere;margin:1.2mm 0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.asset-id{font-size:9pt;font-weight:800;letter-spacing:.4pt}.serial{font-size:6pt;line-height:1.2;overflow-wrap:anywhere;margin-top:.7mm}.qr{align-self:center;display:flex;flex-direction:column;align-items:center}.qr svg{display:block;width:100%;height:auto}.qr small{font-size:5.5pt;font-weight:600;margin-top:.3mm;text-align:center}
@page{size:{{ width }}mm {{ height }}mm;margin:0}
@media print{html,body{width:{{ width }}mm;height:{{ height }}mm;background:#fff;margin:0}.controls{display:none}.preview{display:block;padding:0}.asset-label{box-shadow:none;outline:0;break-inside:avoid;print-color-adjust:exact;-webkit-print-color-adjust:exact}}
@media(max-width:600px){.controls{margin:12px;padding:16px}form{grid-template-columns:1fr}.buttons{grid-column:1}}
</style>
</head>
<body>
<section class="controls">
<h1>Label til jeres hardware</h1><p>Print i 100 % størrelse uden sidehoved og sidefod. QR-koden åbner hardwarekortet i Hub.</p>
<form method="get">
<div><label for="size">Labelstørrelse</label><select id="size" name="size">{% for value, text in [('62x40','62 × 40 mm'),('62x29','62 × 29 mm'),('89x36','89 × 36 mm')] %}<option value="{{ value }}" {% if size == value %}selected{% endif %}>{{ text }}</option>{% endfor %}</select></div>
<div><label for="base_url">Hub-adresse til QR-koden</label><input type="url" id="base_url" name="base_url" value="{{ base_url }}" required><small>Brug en adresse, telefonen kan åbne.</small></div>
<div class="buttons"><button type="submit">Opdater label</button><button type="button" onclick="window.print()">Print label</button><a class="back" href="/hardware/{{ hardware.id }}">Tilbage til delen</a></div>
</form>
<p class="target">QR-link: <a href="{{ target_url }}">{{ target_url }}</a></p>
</section>
<div class="preview">
<article class="asset-label" aria-label="Hardwarelabel">
<div class="label-copy"><div class="brand">BMC NETWORKS · UDSTYR</div><div class="name">{{ hardware.brand or '' }} {{ hardware.model or 'Hardware' }}</div><div><div class="asset-id">BMC-{{ '%05d'|format(hardware.id) }}</div>{% if hardware.serial_number %}<div class="serial">S/N {{ hardware.serial_number }}</div>{% endif %}</div></div>
<div class="qr">{{ qr_svg|safe }}<small>Scan for hardwarekort</small></div>
</article>
</div>
</body>
</html>

View File

@ -22,7 +22,7 @@
.detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; }
.detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; }
.detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; }
.detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); }
.detail-section-nav a:hover { color:var(--accent); background:var(--theme-surface, #fff); transform:translateY(-1px); }
.detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; }
.detail-metrics-grid > [class*="col-"] { width:auto; padding:0; }
@ -34,7 +34,7 @@
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
}
.allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); }
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:var(--theme-surface, #fff); border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
.allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; }
.detail-metric {
@ -132,12 +132,12 @@
.status-pill.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
color: var(--theme-success-text, #146c43);
}
.status-pill.inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
color: var(--theme-text, #495057);
}
.status-pill.pending,

View File

@ -87,7 +87,7 @@
font-size: 0.72rem;
font-weight: 700;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
margin-right: 0.35rem;
}
@ -105,12 +105,12 @@
.internet-status.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
color: var(--theme-success-text, #146c43);
}
.internet-status.inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
color: var(--theme-text, #495057);
}
.internet-status.pending,
@ -174,12 +174,12 @@
white-space:nowrap;
}
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:#146c43; }
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:var(--theme-success-text, #146c43); }
.invoice-sync-status.warning,
.invoice-sync-status.skipped,
.invoice-sync-status.not_logged { background:rgba(255,193,7,.2); color:#765a02; }
.invoice-sync-status.error { background:rgba(220,53,69,.14); color:#b02a37; }
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:#084298; }
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:var(--theme-primary-text, #084298); }
.invoice-sync-error { max-width:420px; white-space:normal; color:#b02a37; font-size:.8rem; }
@media (max-width: 991px) {

View File

@ -91,7 +91,7 @@
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
font-size: 0.78rem;
font-weight: 700;
}

View File

@ -52,18 +52,18 @@
}
.status-ok {
color: #0f5132;
background: #d1e7dd;
color: var(--theme-success-text, #0f5132);
background: var(--theme-success-bg, #d1e7dd);
}
.status-down {
color: #842029;
background: #f8d7da;
color: var(--theme-danger-text, #842029);
background: var(--theme-danger-bg, #f8d7da);
}
.status-unknown {
color: #664d03;
background: #fff3cd;
color: var(--theme-warning-text, #664d03);
background: var(--theme-warning-bg, #fff3cd);
}
.links-container {
@ -263,7 +263,7 @@
border-radius: 999px;
padding: 0.1rem 0.4rem;
background: rgba(15, 76, 117, 0.1);
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
@ -650,6 +650,7 @@
{% endblock %}
{% block extra_js %}
<script src="/static/js/link-targets.js?v=20261005-1"></script>
<script>
const urlParams = new URLSearchParams(window.location.search);
const customerIdFilter = urlParams.get('customer_id');
@ -735,22 +736,14 @@
return `${host}${port}`;
}
function buildOpenHref(link) {
if (link.url && String(link.url).trim()) {
return String(link.url).trim();
}
if (link.host && String(link.host).trim()) {
const raw = String(link.host).trim();
if (/^https?:\/\//i.test(raw)) {
return raw;
}
if (link.type === 'http') {
return `http://${raw}`;
}
return `//${raw}`;
}
return '';
}
function buildOpenHref(link) { return LinkTargets.href(link); }
window.copySshCommand = async id => {
const link=state.links.find(row=>Number(row.id)===Number(id));
const target=link && LinkTargets.sshTarget(link);
if(!target)return;
try { await navigator.clipboard.writeText(target.command); }
catch { prompt('Kopiér SSH-kommandoen og kør den i din terminal:',target.command); }
};
function formatDate(value) {
if (!value) return '-';
@ -869,7 +862,7 @@
<div class="flame-content">
<div class="flame-title">
${canOpen
? `<a href="${escapeHtml(openHref)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(link.name || '')}">${escapeHtml(link.name || '-')}</a>`
? `<a href="${escapeHtml(openHref)}" ${link.type === 'ssh' ? 'title="Åbn i din SSH-klient (kræver en installeret SSH-linkhandler)"' : 'target="_blank" rel="noopener noreferrer"'}>${escapeHtml(link.name || '-')}</a>`
: `<span>${escapeHtml(link.name || '-')}</span>`}
</div>
<div class="flame-subtitle">
@ -879,6 +872,7 @@
</div>
</div>
<div class="flame-actions">
${link.type === 'ssh' && LinkTargets.sshTarget(link) ? `<button class="btn btn-sm btn-outline-secondary" onclick="copySshCommand(${link.id}); event.preventDefault();" title="Kopiér SSH-kommando"><i class="bi bi-copy"></i></button>` : ''}
${hasVault ? `<button class="btn btn-sm text-primary" onclick="resolveVault(${link.id}); event.preventDefault();" title="Hent Vault Credentials"><i class="bi bi-shield-lock"></i></button>` : ''}
<button class="btn btn-sm text-secondary" onclick="openEditModal(${link.id}); event.preventDefault();" title="Rediger"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm text-danger" onclick="deleteLink(${link.id}, '${escapeHtml(link.name || '')}'); event.preventDefault();" title="Slet"><i class="bi bi-trash"></i></button>
@ -927,7 +921,7 @@
<div class="d-flex align-items-center gap-2">
<span class="flame-status-dot ${status}" title="Status: ${status}"></span>
${canOpen
? `<a class="list-name" href="${escapeHtml(openHref)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.name || '-')}</a>`
? `<a class="list-name" href="${escapeHtml(openHref)}" ${link.type === 'ssh' ? 'title="Åbn i din SSH-klient (kræver en installeret SSH-linkhandler)"' : 'target="_blank" rel="noopener noreferrer"'}>${escapeHtml(link.name || '-')}</a>`
: `<span class="list-name">${escapeHtml(link.name || '-')}</span>`}
</div>
</td>
@ -937,6 +931,7 @@
<td>${statusPill(status)}</td>
<td class="text-end">
<div class="list-actions">
${link.type === 'ssh' && LinkTargets.sshTarget(link) ? `<button class="btn btn-sm btn-outline-secondary" onclick="copySshCommand(${link.id}); event.preventDefault();" title="Kopiér SSH-kommando"><i class="bi bi-copy"></i></button>` : ''}
${hasVault ? `<button class="btn btn-sm btn-outline-primary" onclick="resolveVault(${link.id}); event.preventDefault();" title="Vault"><i class="bi bi-shield-lock"></i></button>` : ''}
<button class="btn btn-sm btn-outline-secondary" onclick="openEditModal(${link.id}); event.preventDefault();" title="Rediger"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteLink(${link.id}, '${escapeHtml(link.name || '')}'); event.preventDefault();" title="Slet"><i class="bi bi-trash"></i></button>

View File

@ -1546,7 +1546,8 @@ async def get_location_audit(
@router.get("/locations/search", response_model=LocationSearchResponse)
async def search_locations(
q: str = Query(..., min_length=1, max_length=255, description="Search query (name or address)"),
limit: int = Query(10, ge=1, le=100)
limit: int = Query(10, ge=1, le=100),
customer_id: Optional[int] = Query(None, ge=1),
):
"""
Search for locations by name or address.
@ -1562,31 +1563,38 @@ async def search_locations(
search_term = f"%{q}%"
# Search locations_locations (case-insensitive match on name, street, city)
search_query = """
customer_filter = " AND customer_id = %s" if customer_id is not None else ""
search_query = f"""
SELECT * FROM locations_locations
WHERE deleted_at IS NULL
{customer_filter}
AND (
name ILIKE %s
OR address_street ILIKE %s
OR address_postal_code ILIKE %s
OR address_city ILIKE %s
)
ORDER BY name ASC
LIMIT %s
"""
results = execute_query(search_query, (search_term, search_term, search_term, limit))
params = ([customer_id] if customer_id is not None else []) + [search_term] * 4 + [limit]
results = execute_query(search_query, tuple(params))
# Get total count (without limit) for the search
count_query = """
count_query = f"""
SELECT COUNT(*) as total FROM locations_locations
WHERE deleted_at IS NULL
{customer_filter}
AND (
name ILIKE %s
OR address_street ILIKE %s
OR address_postal_code ILIKE %s
OR address_city ILIKE %s
)
"""
count_result = execute_query(count_query, (search_term, search_term, search_term))
count_params = ([customer_id] if customer_id is not None else []) + [search_term] * 4
count_result = execute_query(count_query, tuple(count_params))
total_count = count_result[0]['total'] if count_result else 0
locations = [Location(**row) for row in results]

View File

@ -588,6 +588,29 @@ def detail_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result
related_cases = execute_query(
"""
WITH RECURSIVE location_tree AS (
SELECT id, parent_location_id, ARRAY[id] AS path
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL
UNION ALL
SELECT child.id, child.parent_location_id, parent.path || child.id
FROM locations_locations child
JOIN location_tree parent ON child.parent_location_id = parent.id
WHERE child.deleted_at IS NULL AND NOT child.id = ANY(parent.path)
)
SELECT DISTINCT s.id, s.titel, s.status, s.template_key, s.updated_at,
s.customer_id, ll.id AS matched_location_id, ll.name AS matched_location_name
FROM location_tree tree
JOIN sag_lokationer sl ON sl.location_id = tree.id AND sl.deleted_at IS NULL
JOIN sag_sager s ON s.id = sl.sag_id AND s.deleted_at IS NULL
JOIN locations_locations ll ON ll.id = tree.id
ORDER BY s.updated_at DESC NULLS LAST, s.id DESC
""",
(id,),
) or []
hierarchy = []
current_parent_id = location.get("parent_location_id")
while current_parent_id:
@ -907,6 +930,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["hierarchy"] = hierarchy
location["children"] = children
location["relation_tree"] = relation_tree
location["related_cases"] = related_cases
location["direct_child_nodes"] = direct_child_nodes
location["related_location_count"] = max(len(relation_rows) - 1, 0)
location["same_customer_locations"] = same_customer_locations

View File

@ -19,7 +19,7 @@
}
.relation-summary.empty {
background: #f8fafc;
background: var(--theme-surface-soft, #f8fafc);
border-style: solid;
border-color: rgba(0, 0, 0, 0.08);
}

View File

@ -6,7 +6,7 @@
<style>
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
.patch-port { min-height: 45px; border-radius: .35rem; background: var(--theme-surface-soft, #f4f6f8); border: 2px solid #aeb7c1; color: var(--theme-text, #263645); font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
button.patch-port:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; }
@ -90,14 +90,14 @@
}
.locations-detail-page .case-status-chip.open {
background: #dcfce7;
color: #15803d;
background: var(--theme-success-bg, #dcfce7);
color: var(--theme-text, #15803d);
border-color: #86efac;
}
.locations-detail-page .case-status-chip.closed {
background: #f1f5f9;
color: #475569;
background: var(--theme-surface-soft, #f1f5f9);
color: var(--theme-text, #475569);
border-color: #cbd5e1;
}
@ -331,7 +331,7 @@
.locations-detail-page .tree-card-title { font-weight: 750; }
.locations-detail-page .location-tree-card .tree-card-meta { color: var(--text-secondary); font-size: 0.74rem; margin-left: auto; text-align: right; }
.locations-detail-page .location-tree-card.current .tree-card-meta { color: rgba(255, 255, 255, 0.82); }
.locations-detail-page .location-photo-thumb { width: 180px; border: 1px solid rgba(15,76,117,.14); border-radius: .7rem; background: #fff; overflow: hidden; padding: 0; text-align: left; }
.locations-detail-page .location-photo-thumb { width: 180px; border: 1px solid rgba(15,76,117,.14); border-radius: .7rem; background: var(--theme-surface, #fff); overflow: hidden; padding: 0; text-align: left; }
.locations-detail-page .location-photo-thumb img { display: block; width: 100%; height: 120px; object-fit: cover; }
.locations-detail-page .location-photo-thumb:hover { border-color: var(--loc-accent); box-shadow: 0 6px 16px rgba(15,76,117,.13); }
.locations-detail-page .location-photo-caption { padding: .5rem .6rem; font-size: .78rem; }
@ -506,6 +506,12 @@
<i class="bi bi-diagram-3 me-2"></i>Relationer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="casesTab" data-bs-toggle="tab" data-bs-target="#casesContent" type="button" role="tab" aria-controls="casesContent" aria-selected="false">
<i class="bi bi-ticket-detailed me-2"></i>Sager
<span class="location-tab-count-badge ms-1">{{ location.related_cases|length if location.related_cases else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="hardwareTab" data-bs-toggle="tab" data-bs-target="#hardwareContent" type="button" role="tab" aria-controls="hardwareContent" aria-selected="false">
<i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
@ -521,6 +527,39 @@
<!-- Tab Content -->
<div class="tab-content">
<!-- Cases for this location and descendant locations -->
<div class="tab-pane fade" id="casesContent" role="tabpanel" aria-labelledby="casesTab">
<div class="card border-0 mb-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h5 class="card-title mb-0">Sager på lokationen</h5>
<div class="small text-muted">Sager knyttet til denne lokation eller en af dens underlokationer.</div>
</div>
<span class="badge text-bg-primary">{{ location.related_cases|length if location.related_cases else 0 }} sager</span>
</div>
<div class="card-body">
{% if location.related_cases %}
<div class="list-group list-group-flush">
{% for case in location.related_cases %}
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3 flex-wrap" href="/sag/{{ case.id }}/v5">
<span class="d-flex align-items-center gap-3">
<span class="tree-card-icon"><i class="bi bi-ticket-detailed"></i></span>
<span><strong>SAG-{{ case.id }} · {{ case.titel }}</strong><span class="d-block small text-muted">Lokation: {{ case.matched_location_name }}</span></span>
</span>
<span class="badge {{ 'text-bg-success' if case.status == 'åben' else 'text-bg-secondary' }}">{{ case.status or 'Ukendt status' }}</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
Der er ingen sager knyttet til denne lokation eller dens underlokationer.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Tab 1: Information -->
<div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab">
<section class="card border-0 mb-4">

View File

@ -19,7 +19,7 @@
}
.relation-summary.empty {
background: #f8fafc;
background: var(--theme-surface-soft, #f8fafc);
border-style: solid;
border-color: rgba(0, 0, 0, 0.08);
}

View File

@ -56,7 +56,7 @@
color: var(--text-secondary);
white-space: nowrap;
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
background: #f8fafc;
background: var(--theme-surface-soft, #f8fafc);
}
.locations-list-page .location-row {
@ -209,12 +209,12 @@
.locations-list-page .status-pill.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
color: var(--theme-success-text, #146c43);
}
.locations-list-page .status-pill.inactive {
background: rgba(108, 117, 125, 0.12);
color: #495057;
color: var(--theme-text, #495057);
}
.locations-list-page .shortcut-hint {

View File

@ -7,16 +7,16 @@
.mc-stat { border:0; border-radius:14px; box-shadow:0 5px 20px rgba(20,36,58,.07); }
.mc-label { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#728099; font-weight:700; }
.mc-number { font-size:1.65rem; font-weight:750; }
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:white; }
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:#6b778c; background:#f8fafc; }
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:var(--theme-surface, white); }
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:var(--theme-text, #6b778c); background:var(--theme-surface-soft, #f8fafc); }
.mc-table td { vertical-align:middle; }
.mc-product { max-width:300px; }
.mc-badge { border-radius:999px; padding:.34rem .6rem; font-size:.72rem; font-weight:700; display:inline-block; }
.mc-green { background:#dcfce7; color:#166534; } .mc-yellow { background:#fef3c7; color:#92400e; }
.mc-red { background:#fee2e2; color:#991b1b; } .mc-gray { background:#e5e7eb; color:#374151; }
.mc-blue { background:#dbeafe; color:#1e40af; }
.mc-green { background:var(--theme-success-bg, #dcfce7); color:var(--theme-success-text, #166534); } .mc-yellow { background:var(--theme-warning-bg, #fef3c7); color:var(--theme-warning-text, #92400e); }
.mc-red { background:var(--theme-danger-bg, #fee2e2); color:var(--theme-danger-text, #991b1b); } .mc-gray { background:var(--theme-surface-soft, #e5e7eb); color:var(--theme-text, #374151); }
.mc-blue { background:var(--theme-primary-bg, #dbeafe); color:var(--theme-primary-text, #1e40af); }
.mc-queue { border-left:4px solid var(--mc-blue); }
.mc-source-card { background:#f8fafc; border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
.mc-source-card { background:var(--theme-surface-soft, #f8fafc); border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
.mc-explanation { margin:0; padding-left:1.1rem; color:#56647a; font-size:.88rem; }
.mc-actions .btn { margin:2px; }
@media(max-width:768px){ .mc-hero{padding:18px}.mc-product{max-width:180px}.mc-actions{min-width:170px} }

View File

@ -165,7 +165,7 @@ class OrdreEconomicExportService:
product_map: Dict[int, str] = {}
if product_ids:
product_rows = execute_query(
"""SELECT id, name, sku_internal, ean, short_description, long_description,
"""SELECT id, name, sku_internal, economic_product_number, deleted_at, status, ean, short_description, long_description,
sales_price, economic_product_group_number
FROM products WHERE id = ANY(%s)""",
(product_ids,),
@ -206,6 +206,13 @@ class OrdreEconomicExportService:
if product_id is not None:
try:
product_number = product_map.get(int(product_id))
if line.get('source_type') == 'invoice_fee':
fee_product = product_details.get(int(product_id))
if not fee_product or fee_product.get('deleted_at') or fee_product.get('status', 'active') != 'active':
raise HTTPException(409, 'Gebyrvaren er slettet eller inaktiv. Ordren blev ikke sendt.')
product_number = fee_product.get('economic_product_number') or product_number
if not product_number:
raise HTTPException(409, 'Gebyrvaren mangler varenummer. Ordren blev ikke sendt.')
except (TypeError, ValueError):
product_number = None

View File

@ -1,3 +1,4 @@
from app.services.invoice_fee_service import with_invoice_fee
import logging
import json
from datetime import datetime
@ -9,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()
@ -36,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():
@ -47,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)
@ -187,7 +208,9 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
try:
user_id = _get_user_id_from_request(http_request)
from app.products.backend.economic_documents import active_connection
if active_connection() or request.create_missing_products:
from app.workflows.export_guard import is_workflow_draft
workflow_guarded = is_workflow_draft(request.draft_id)
if active_connection() or request.create_missing_products or workflow_guarded:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
@ -204,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
""",
@ -214,7 +237,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
raise HTTPException(status_code=404, detail="Draft not found")
previous_status = (draft_row.get("sync_status") or "pending").strip().lower()
if previous_status in {"exported", "posted", "paid"} and not request.force_export:
if previous_status in {"exported", "posted", "paid"} and not request.force_export and not workflow_guarded:
raise HTTPException(
status_code=409,
detail=f"Draft already exported with status '{previous_status}'. Use force_export=true to retry.",
@ -231,21 +254,35 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
)
line_payload = [line.model_dump() for line in request.lines]
await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id)
export_result = await ordre_economic_export_service.export_order(
customer_id=request.customer_id,
lines=line_payload,
notes=request.notes,
layout_number=request.layout_number,
user_id=user_id,
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
recovered_export = recovered_result(request.draft_id)
if recovered_export is None:
await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id)
if not request.draft_id:
line_payload = with_invoice_fee(line_payload, request.customer_id)
export_arguments = dict(customer_id=request.customer_id,lines=line_payload,notes=request.notes,
layout_number=request.layout_number,user_id=user_id,
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
currency=request.currency,
create_missing_products=request.create_missing_products,
export_reference=export_idempotency_key,
external_id=request.draft_id,
)
currency=request.currency,create_missing_products=request.create_missing_products,
export_reference=export_idempotency_key,external_id=request.draft_id)
if recovered_export is not None:
export_result = recovered_export
elif workflow_guarded:
from app.workflows.export_guard import guarded_export
export_result = await guarded_export(request.draft_id,ordre_economic_export_service.export_order,export_arguments)
else:
export_result = await ordre_economic_export_service.export_order(**export_arguments)
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
if workflow_guarded:
export_idempotency_key = export_result.get('workflow_export_reference') or export_idempotency_key
exported_line_keys = export_result.get('exported_line_keys',[]) if workflow_guarded else [line.get("line_key") for line in line_payload if line.get("line_key")]
export_result["exported_line_keys"] = exported_line_keys
if request.draft_id:
@ -427,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:
@ -471,6 +511,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
"""Create a new ordre draft."""
try:
await _check_draft_prices(http_request, request.customer_id, request.lines)
request.lines = with_invoice_fee(request.lines, request.customer_id)
user_id = _get_user_id_from_request(http_request)
from app.core.database import execute_query

View File

@ -399,6 +399,7 @@
}
function sourceBadge(type) {
if (type === 'invoice_fee') return '<span class="badge bg-warning text-dark">Gebyr</span>';
if (type === 'subscription') return '<span class="badge bg-primary">Abonnement</span>';
if (type === 'hardware') return '<span class="badge bg-secondary">Hardware</span>';
if (type === 'manual') return '<span class="badge bg-info">Manuel</span>';
@ -472,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>`;
@ -649,7 +652,7 @@
quantity: Number(line.quantity || 1),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
amount: Number(line.amount || 0),
amount: Number(line.amount ?? (Number(line.quantity || 1) * Number(line.unit_price || 0) * (1 - Number(line.discount_percentage || 0) / 100))),
};
}

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

@ -0,0 +1,329 @@
"""Personal work desk, reusing queue, planner and time-entry contracts."""
from datetime import date, datetime, time, timedelta, timezone
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from fastapi.encoders import jsonable_encoder
from psycopg2.extras import Json
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 . import work
from .router import TZ, _aware, _can_edit
def require_desk_schema():
work.require_schema()
if not execute_query_single("SELECT 1 FROM information_schema.columns WHERE table_name='planner_queue_versions' AND column_name='desk_manual_order'"):
raise HTTPException(503, 'Arbejdsbord afventer databaseopdatering 1073.')
router = APIRouter(dependencies=[Depends(require_desk_schema)])
def daily_task(row, day, now):
"""Expose why work is due today; never infer an individual case estimate."""
start = datetime.combine(day, time.min, TZ)
end = start + timedelta(days=1)
deadline = work.db_utc(row['deadline']).astimezone(TZ) if row.get('deadline') else None
slots = row.get('planned_slots') or []
today_slots = [s for s in slots if _aware(datetime.fromisoformat(str(s['starts_at']))) < end
and _aware(datetime.fromisoformat(str(s['ends_at']))) > start]
planned = round(sum(max(0, (min(_aware(datetime.fromisoformat(str(s['ends_at']))), end) -
max(_aware(datetime.fromisoformat(str(s['starts_at']))), start)).total_seconds()) / 60
for s in today_slots))
blocked = None
if row.get('work_status') in {'waiting', 'paused'} or str(row.get('status') or '').lower() in work.WAITING:
blocked = 'Afventer' if row.get('work_status') != 'paused' else 'På pause'
if row.get('deferred_until_case_id') and not work.ready({**row, 'start_date': None, 'work_status': 'ready'}, now):
blocked = f"Afhænger af sag #{row['deferred_until_case_id']}"
if row.get('snoozed_until') and _aware(row['snoozed_until']) > now:
blocked = 'Udsat'
if row.get('deferred_until') and work.db_utc(row['deferred_until']) > now:
blocked = 'Udsat'
remaining = work.remaining(row)
missing = None if remaining is None else max(0, remaining - int(row.get('future_planned_minutes') or 0))
overdue = bool(deadline and deadline < now)
due = bool(deadline and deadline < end)
included = row.get('work_status') != 'done' and (bool(today_slots) or due or not row.get('start_date') or row['start_date'] <= day)
if overdue:
reason, rank = 'Deadline overskredet', 0
elif today_slots:
reason, rank = 'Tid reserveret i dag', 1
elif due:
reason, rank = 'Deadline i dag', 2
elif row.get('start_date') == day:
reason, rank = 'Starter i dag', 3
else:
reason, rank = 'Åben opgave · kan fortsættes i dag', 4
if blocked:
reason = blocked + ' · ' + reason
if row.get('start_date') and row['start_date'] > day and due:
reason += ' · start ligger efter deadline'
after_deadline = bool(deadline and any(_aware(datetime.fromisoformat(str(s['ends_at']))) > deadline for s in slots))
if after_deadline:
reason += ' · tid reserveret efter deadline'
next_slot = min((str(s['starts_at']) for s in today_slots), default='9999')
priority = {'urgent': 0, 'akut': 0, 'high': 1, 'høj': 1, 'normal': 2, 'low': 3}.get(str(row.get('priority') or '').lower(), 2)
return {**row, 'in_day': included, 'blocked_reason': blocked, 'priority_reason': reason,
'remaining_work_minutes': remaining, 'missing_minutes': missing, 'planned_today_minutes': planned,
'today_slots': today_slots, 'ready': work.ready(row, now),
'booking_after_deadline': after_deadline,
'_rank': (bool(blocked), rank, deadline.isoformat() if deadline else '9999', next_slot,
priority, row.get('start_date') or date.max, missing if missing is not None else 10**9, row['sag_id'])}
@router.get('/desk')
def desk(day: date | None = None, user: dict = Depends(get_current_user)):
uid = int(user['id'])
work.require_target(user, uid)
now = datetime.now(TZ)
day = day or now.date()
with atomic_transaction():
execute_query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', fetch=False)
version = execute_query_single('SELECT * FROM planner_queue_versions WHERE user_id=%s', (uid,)) or {}
rows = work.queue_rows(uid, limit=10001, user=user)
if len(rows) > 10000:
raise HTTPException(422, 'Køen er for stor til Arbejdsbord. Brug opgavevisningen til at afgrænse den.')
# Past slots on the chosen day must remain visible even after they end.
bookings = execute_query('''SELECT sag_id,id,event_id,starts_at,ends_at FROM planner_allocations
WHERE user_id=%s AND sag_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s''',
(uid, [r['sag_id'] for r in rows], datetime.combine(day+timedelta(days=1), time.min, TZ), datetime.combine(day,time.min,TZ))) or []
contacts = execute_query('''SELECT DISTINCT ON (sk.sag_id) sk.sag_id,c.id,
TRIM(CONCAT_WS(' ',c.first_name,c.last_name)) AS name,c.email,COALESCE(NULLIF(c.mobile,''),c.phone) AS phone
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
WHERE sk.sag_id=ANY(%s) AND sk.deleted_at IS NULL ORDER BY sk.sag_id,sk.is_primary DESC,sk.id''',
([r['sag_id'] for r in rows],)) or []
by_case = {c['sag_id']: c for c in contacts}
def slot_key(slot):
# jsonb future slots use ISO strings; today's SQL rows use datetimes.
# Normalize both before merging, preserving simultaneous events.
return (slot.get('event_id'),
_aware(datetime.fromisoformat(str(slot['starts_at']))).astimezone(timezone.utc).isoformat(),
_aware(datetime.fromisoformat(str(slot['ends_at']))).astimezone(timezone.utc).isoformat())
result = []
for row in rows:
merged = {slot_key(s): s for s in row.get('planned_slots') or []}
merged.update({slot_key(b): b for b in bookings if b['sag_id'] == row['sag_id']})
row['planned_slots'] = list(merged.values())
task = daily_task(row, day, now)
task['contact'] = by_case.get(row['sag_id'])
task['is_new'] = not row.get('desk_seen_at')
result.append(task)
recommended = [r['sag_id'] for r in sorted(result, key=lambda r: r['_rank'])]
manual = bool(version.get('desk_manual_order'))
if not manual:
result.sort(key=lambda r: r['_rank'])
else:
result.sort(key=lambda r: (r['queue_position'], r['sag_id']))
for row in result:
row.pop('_rank')
from .capacity import working_capacity
horizon_start = min(datetime.combine(day+timedelta(days=1),time.min,TZ), max(datetime.combine(day,time.min,TZ), now))
horizon_end = datetime.combine(day+timedelta(days=1),time.min,TZ)
schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active', (uid,)) or []
occupied = execute_query('''SELECT starts_at,ends_at FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
UNION ALL SELECT starts_at,ends_at FROM planner_absences WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
UNION ALL SELECT starts_at,ends_at FROM planner_external_busy WHERE user_id=%s AND starts_at<%s AND ends_at>%s
UNION ALL SELECT starts_at,ends_at FROM quick_time_items WHERE user_id=%s AND state='planned' AND starts_at<%s AND ends_at>%s''',
(uid,horizon_end,horizon_start)*4) or []
capacity = working_capacity(schedules, [(_aware(b['starts_at']), _aware(b['ends_at'])) for b in occupied], horizon_start, horizon_end)
capacity['configured'] = bool(schedules)
return {'items': result, 'recommended_ids': recommended, 'version': version.get('version', 1),
'stored_order_ids': [r['sag_id'] for r in sorted(result, key=lambda r: (r['queue_position'], r['sag_id']))],
'manual_order': manual, 'day': day, 'can_edit': _can_edit(user, uid), 'capacity': capacity}
class DeskOrder(BaseModel):
version: int = Field(ge=1)
mode: Literal['manual', 'recommended'] = 'manual'
ids: list[int] = Field(default_factory=list, max_length=10000)
@router.put('/desk/order')
def desk_order(payload: DeskOrder, user: dict = Depends(get_current_user)):
uid = int(user['id'])
work.require_target(user, uid, True)
with atomic_transaction():
version = work.lock_queue(uid)
if version != payload.version:
raise HTTPException(409, 'Køen er ændret. Genindlæs og prøv igen.')
if len(set(payload.ids)) != len(payload.ids):
raise HTTPException(400, 'En opgave må kun forekomme én gang')
if payload.mode == 'manual' or payload.ids:
allowed = {r['sag_id'] for r in work.queue_rows(uid, limit=10001, user=user)}
if set(payload.ids) != allowed:
raise HTTPException(409, 'Opgaverne er ændret. Genindlæs før sortering.')
for position, sid in enumerate(payload.ids, 1):
execute_query('UPDATE sag_case_assignees SET queue_position=%s,version=version+1 WHERE user_id=%s AND sag_id=%s',
(position*1024, uid, sid), fetch=False)
execute_query('UPDATE planner_queue_versions SET desk_manual_order=%s WHERE user_id=%s', (payload.mode == 'manual', uid), fetch=False)
work.bump_queue(uid)
work.audit(user, 'desk_order', 'assignee', uid, None, payload.model_dump())
return {'version': version+1}
@router.post('/desk/seen')
def desk_seen(user: dict = Depends(get_current_user)):
uid = int(user['id'])
work.require_target(user, uid, True)
# Only acknowledge the assignments this user is allowed to see.
ids = [r['sag_id'] for r in work.queue_rows(uid, limit=10000, user=user)]
execute_query('UPDATE sag_case_assignees SET desk_seen_at=NOW() WHERE user_id=%s AND sag_id=ANY(%s) AND desk_seen_at IS NULL', (uid, ids), fetch=False)
return {'ok': True}
@router.get('/desk/recent')
def recent_work(user: dict = Depends(get_current_user)):
work.require_target(user, int(user['id']))
hidden = list(work.hidden_case_ids(user))
entries = execute_query('''SELECT t.id,t.sag_id,t.description,t.aktiv_timer,t.paused_at,t.slut_tid,s.titel,
COALESCE(t.slut_tid,t.paused_at,t.start_tid,t.created_at) AS last_activity
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
WHERE t.medarbejder_id=%s AND t.status<>'rejected'
AND (t.sag_id IS NULL OR (s.deleted_at IS NULL AND NOT(t.sag_id=ANY(%s))))
ORDER BY (t.aktiv_timer AND t.slut_tid IS NULL) DESC,last_activity DESC,t.id DESC LIMIT 20''',
(user['id'], hidden)) or []
return {'entries': entries}
class DeskTimer(BaseModel):
description: str = Field(min_length=1, max_length=2000)
sag_id: int | None = Field(default=None, gt=0)
@router.post('/desk/timer')
async def start_desk_timer(payload: DeskTimer, user: dict = Depends(get_current_user)):
work.require_target(user, int(user['id']))
if not payload.description.strip():
raise HTTPException(400, 'Angiv hvad du arbejder på')
if payload.sag_id:
require_case(user, payload.sag_id)
with atomic_transaction():
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (user['id'],))
if execute_query_single('SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND aktiv_timer AND slut_tid IS NULL', (user['id'],)):
raise HTTPException(409, 'Du har en aktiv timer. Pause eller stop den først.')
if payload.sag_id:
from app.timetracking.backend.router import start_live_timer_v1
return await start_live_timer_v1(payload={'sag_id': payload.sag_id, 'description': payload.description}, current_user=user)
now = datetime.now(timezone.utc).replace(tzinfo=None)
name = execute_query_single('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s', (user['id'],))['name']
entry = execute_query_single('''INSERT INTO tmodule_times(description,original_hours,worked_date,user_name,status,
billable,billing_method,start_tid,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status,
medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type)
VALUES(%s,0.01,%s,%s,'pending',false,'draft',%s,NULL,0,'manuel','manuel','kladde',%s,true,1,false,'other') RETURNING id''',
(payload.description.strip(), datetime.now(TZ).date(), name, now, user['id']))
return {'entry': entry}
class DeskTime(BaseModel):
request_id: UUID
sag_id: int = Field(gt=0)
description: str = Field(min_length=1, max_length=2000)
starts_at: datetime
minutes: int = Field(ge=1, le=1440)
@model_validator(mode='after')
def valid_time(self):
if not self.starts_at.tzinfo or not self.description.strip():
raise ValueError('Angiv beskrivelse og tidspunkt med tidszone')
return self
@router.post('/desk/time')
async def register_case_time(payload: DeskTime, user: dict = Depends(get_current_user)):
work.require_target(user, int(user['id']))
require_case(user, payload.sag_id)
from app.timetracking.backend.router import create_manual_time_v1
value = payload.model_dump(mode='json')
with atomic_transaction():
execute_query('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))', (str(payload.request_id),))
old = execute_query_single('SELECT * FROM quick_time_batches WHERE request_id=%s', (str(payload.request_id),))
if old:
if old['actor_id'] != user['id'] or old['payload'] != value:
raise HTTPException(409, 'Gemningen tilhører andre oplysninger')
return old['result']
result = await create_manual_time_v1(payload={'sag_id': payload.sag_id, 'description': payload.description.strip(),
'start_tid': payload.starts_at.isoformat(), 'slut_tid': (payload.starts_at+timedelta(minutes=payload.minutes)).isoformat(),
'faktisk_tid_min': payload.minutes, 'worked_date': payload.starts_at.astimezone(TZ).date().isoformat()}, current_user=user)
result = jsonable_encoder(result)
execute_query('INSERT INTO quick_time_batches(request_id,actor_id,payload,result) VALUES(%s,%s,%s,%s)',
(str(payload.request_id), user['id'], Json(value), Json(result)), fetch=False)
return result
class MeetingAttendance(BaseModel):
user_id: int = Field(gt=0)
starts_at: datetime
ends_at: datetime
@model_validator(mode='after')
def valid_times(self):
if not self.starts_at.tzinfo or not self.ends_at.tzinfo or not timedelta(0) < self.ends_at-self.starts_at <= timedelta(days=1):
raise ValueError('Angiv start og slut med tidszone, højst 24 timer')
return self
class MeetingRegistration(BaseModel):
version: int = Field(ge=1)
attendees: list[MeetingAttendance] = Field(min_length=1, max_length=100)
def meeting_access(event_id, user, lock=False):
row = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL' + (' FOR UPDATE' if lock else ''), (event_id,))
if not row or row['kind'] not in {'meeting', 'internal'} or row.get('sag_id'):
raise HTTPException(404, 'Internt møde uden sag findes ikke')
participants = execute_query('SELECT a.user_id,COALESCE(u.full_name,u.username) AS name FROM planner_allocations a JOIN users u ON u.user_id=a.user_id WHERE a.event_id=%s AND a.deleted_at IS NULL', (event_id,)) or []
if not participants or (int(user['id']) not in {p['user_id'] for p in participants} and not all(_can_edit(user,p['user_id']) for p in participants)):
raise HTTPException(403, 'Du har ikke adgang til mødet')
return row, participants
@router.get('/desk/events/{event_id}/attendance')
def meeting_attendance(event_id: int, user: dict = Depends(get_current_user)):
from app.timetracking.backend.quick_time import can_register
work.require_target(user, int(user['id']))
row, people = meeting_access(event_id, user)
registered = execute_query('SELECT medarbejder_id FROM tmodule_times WHERE planner_event_id=%s', (event_id,)) or []
done = {p['medarbejder_id'] for p in registered}
return {'event': row, 'participants': [{**p, 'registered': p['user_id'] in done,
'can_register': can_register(user, p['user_id'])} for p in people]}
@router.post('/desk/events/{event_id}/register')
def register_meeting(event_id: int, payload: MeetingRegistration, user: dict = Depends(get_current_user)):
from app.timetracking.backend.quick_time import can_register
work.require_target(user, int(user['id']))
if len({a.user_id for a in payload.attendees}) != len(payload.attendees):
raise HTTPException(400, 'En deltager må kun forekomme én gang')
with atomic_transaction():
row, people = meeting_access(event_id, user, lock=True)
if row['version'] != payload.version:
raise HTTPException(409, 'Mødet er ændret. Genindlæs før registrering.')
names = {p['user_id']: p['name'] for p in people}
for attendee in payload.attendees:
if attendee.user_id not in names or not can_register(user, attendee.user_id):
raise HTTPException(403, 'Du må ikke registrere tid for denne deltager')
if attendee.ends_at > datetime.now(TZ):
raise HTTPException(400, 'Mødetid kan først registreres, når den er afholdt')
ids = []
for a in payload.attendees:
existing = execute_query_single('SELECT id,start_tid,slut_tid FROM tmodule_times WHERE planner_event_id=%s AND medarbejder_id=%s', (event_id, a.user_id))
if existing:
if work.db_utc(existing['start_tid']) != a.starts_at.astimezone(timezone.utc) or work.db_utc(existing['slut_tid']) != a.ends_at.astimezone(timezone.utc):
raise HTTPException(409, 'Deltageren har allerede registreret en anden varighed. Ret den eksisterende tidsregistrering.')
ids.append(existing['id'])
continue
minutes = max(1, round((a.ends_at-a.starts_at).total_seconds()/60))
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_event_id)
VALUES(%s,%s,%s,%s,'pending',false,'draft',%s,0,'manuel','manuel','kladde',%s,false,1,false,'other',%s,%s,%s) RETURNING id''',
(row['title'],minutes/60,a.starts_at.astimezone(TZ).date(),names[a.user_id],minutes,a.user_id,
work.db_utc(a.starts_at).replace(tzinfo=None),work.db_utc(a.ends_at).replace(tzinfo=None),event_id))
ids.append(entry['id'])
work.audit(user, 'meeting_register', 'event', event_id, None, {'time_entry_ids': ids})
return {'time_entry_ids': ids}

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)
@ -122,11 +138,11 @@ def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, alloc
"""SELECT t.medarbejder_id AS user_id,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS registered_at,
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
WHERE t.medarbejder_id=ANY(%s)
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
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)""",
AND (t.sag_id IS NULL OR (s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)))""",
(user_ids, starts_at, ends_at, list(CLOSED_STATUSES)),
) or []
if allocations is None:
@ -134,6 +150,8 @@ def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, alloc
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
(user_ids, ends_at, starts_at),
) or []
standalone = execute_query("SELECT user_id,starts_at,ends_at FROM quick_time_items WHERE state='planned' AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",(user_ids,ends_at,starts_at)) or []
allocations = list(allocations) + standalone
result = []
current = starts_at.astimezone(TZ).date()
final = (ends_at - timedelta(microseconds=1)).astimezone(TZ).date()
@ -144,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
@ -184,6 +202,7 @@ def _conflicts(user_id, starts_at, ends_at, exclude_id=None) -> tuple[str, dict]
query += " AND id <> %s"
params.append(exclude_id)
overlaps = execute_query(query, tuple(params)) or []
overlaps += execute_query("SELECT ('quick:' || id::text) AS id FROM quick_time_items WHERE state='planned' AND user_id=%s AND starts_at<%s AND ends_at>%s",(user_id,ends_at,starts_at)) or []
prospective = (execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s AND starts_at<%s AND ends_at>%s" + (" AND id<>%s" if exclude_id else ""),
tuple([user_id, ends_at, starts_at] + ([exclude_id] if exclude_id else [])),
@ -203,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}
@ -242,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 (
@ -254,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
@ -325,11 +349,26 @@ def case_allocations(sag_id: int, current_user: dict = Depends(get_current_user)
child.deferred_until, child.deferred_until_case_id, child.deferred_until_status,
COALESCE(owner.full_name,owner.username) AS responsible_name,
waiting.titel AS deferred_case_title,
waiting.status AS deferred_case_current_status
waiting.status AS deferred_case_current_status,
COALESCE(allocation_summary.planned_minutes, 0)::int AS planned_minutes,
COALESCE(allocation_summary.items, '[]'::json) AS allocations
FROM sag_relationer relation
JOIN sag_sager child ON child.id=relation.målsag_id AND child.deleted_at IS NULL
LEFT JOIN users owner ON owner.user_id=child.ansvarlig_bruger_id
LEFT JOIN sag_sager waiting ON waiting.id=child.deferred_until_case_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(EXTRACT(EPOCH FROM (allocation.ends_at-allocation.starts_at))/60), 0) AS planned_minutes,
json_agg(json_build_object(
'starts_at', allocation.starts_at,
'ends_at', allocation.ends_at,
'note', allocation.note,
'user_name', COALESCE(allocation_user.full_name, allocation_user.username, 'Ukendt')
) ORDER BY allocation.starts_at, allocation.id) AS items
FROM planner_allocations allocation
LEFT JOIN users allocation_user ON allocation_user.user_id=allocation.user_id
WHERE allocation.sag_id=child.id AND allocation.deleted_at IS NULL
) allocation_summary ON TRUE
WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
AND relation.deleted_at IS NULL
ORDER BY child.created_at, child.id""",
@ -379,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(
@ -394,18 +436,39 @@ def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(de
+ make_interval(mins => GREATEST(COALESCE(t.faktisk_tid_min,
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int,15))) AS ends_at,
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
t.description, s.titel AS case_title, c.name AS customer_name,
t.description, COALESCE(s.titel,t.description) AS case_title, c.name AS customer_name,
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS user_name
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=t.medarbejder_id
FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id
LEFT JOIN tmodule_customers tc ON tc.id=t.customer_id
LEFT JOIN customers c ON c.id=COALESCE(s.customer_id,tc.hub_customer_id) LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.medarbejder_id=ANY(%s)
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
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)
AND (t.sag_id IS NULL OR (s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)))
ORDER BY starts_at""",
(ids, starts_at, ends_at, list(CLOSED_STATUSES)),
) or []
return {"allocations": allocations, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
standalone_plans = execute_query("""SELECT q.*, q.description AS case_title, COALESCE(c.name,'Intern tid') AS customer_name,
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 []
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)}
@ -416,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()
@ -430,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"]
@ -464,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}
@ -483,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")
@ -512,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,20 @@ 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)
desk_enabled = os.environ.get('WORK_DESK_ENABLED', 'true').lower() not in {'0', 'false', 'no'}
template = 'desk.html' if request.query_params.get('view') == 'queue' and desk_enabled else 'work.html'
return templates.TemplateResponse("modules/planner/templates/" + template, {"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 %}

View File

@ -0,0 +1,30 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Mit arbejdsbord · BMC Hub{% endblock %}
{% block extra_css %}<link rel="stylesheet" href="/static/css/work-desk.css?v=20261101-1">{% endblock %}
{% block content %}
<main id="workDesk" data-user-id="{{ current_user.id }}">
<header class="desk-heading"><div><span class="desk-eyebrow">Min kø · Arbejdsbord</span><h1>Min arbejdsdag</h1><p id="deskDate" class="text-muted mb-0"></p></div><div class="desk-actions"><button class="btn btn-primary" id="deskTime">Registrér tid</button><button class="btn btn-outline-primary" id="deskMeeting">Internt møde</button><button class="btn btn-outline-secondary" id="deskContinue" disabled>Fortsæt sidste arbejde</button><button class="btn btn-outline-secondary" id="deskRefresh" aria-label="Opdatér arbejdsbord"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button></div></header>
<nav class="desk-tabs nav nav-tabs" aria-label="Planlægning">
<a class="nav-link active" aria-current="page" href="/planning?view=queue"><i class="bi bi-person-workspace" aria-hidden="true"></i> Mit arbejde <span>Min dag og min kø</span></a>
<a class="nav-link" href="/planning?view=tasks"><i class="bi bi-list-task" aria-hidden="true"></i> Opgaver <span>Fordel og planlæg</span></a>
<a class="nav-link" href="/planning?view=team"><i class="bi bi-people" aria-hidden="true"></i> Team og kapacitet <span>Se belastning og ledig tid</span></a>
</nav>
<div id="deskError" class="alert alert-danger" role="alert" hidden></div>
<div id="deskStatus" class="desk-status" role="status" aria-live="polite"><span></span><button class="btn btn-sm btn-outline-secondary" id="deskUndo" hidden>Fortryd</button></div>
<section class="desk-stats" aria-label="Dagens overblik"><div><span>Opgaver i dag</span><strong id="deskCount">–</strong></div><div><span>Forventet resterende arbejde</span><strong id="deskEstimate">–</strong></div><div><span>Allokeret i dag</span><strong id="deskAllocated">–</strong></div><div><span>Mangler planlægning</span><strong id="deskMissing">–</strong></div></section>
<section id="deskPressure" class="alert alert-warning desk-pressure" role="alert" hidden></section>
<section id="deskInbox" class="desk-inbox" hidden><div class="desk-section-heading"><h2>Nye tildelinger <span id="deskInboxCount" class="badge text-bg-primary"></span></h2><button class="btn btn-sm btn-outline-secondary" id="deskSeen">Markér som set</button></div><div id="deskInboxItems"></div></section>
<div class="desk-layout">
<section class="desk-table-panel"><header class="desk-section-heading"><div><h2>Dagens opgaver</h2><p id="deskOrderLabel" class="text-muted small mb-0">Anbefalet rækkefølge</p></div><div class="desk-actions"><button class="btn btn-sm btn-outline-primary" id="deskRecommend">Brug anbefalet rækkefølge</button><a class="btn btn-sm btn-outline-secondary" href="/planning?view=tasks&source=queue">Alle opgaver</a></div></header>
<label class="visually-hidden" for="deskSearch">Søg i dagens opgaver</label><input id="deskSearch" type="search" class="form-control form-control-sm desk-search" placeholder="Find sag, kunde eller kontaktperson…">
<div class="desk-table-scroll"><table class="table desk-table"><thead><tr><th scope="col"><span class="visually-hidden">Rækkefølge</span></th><th scope="col">Opgave og kunde</th><th scope="col">Kontaktperson</th><th scope="col">Deadline / start</th><th scope="col">Estimat</th><th scope="col">Allokeret</th><th scope="col">Handlinger</th></tr></thead><tbody id="deskTasks"><tr><td colspan="7">Henter dine opgaver…</td></tr></tbody></table></div>
<p class="small text-muted px-3">Træk i grebet for at ændre rækkefølgen. Træk “Planlæg” til kalenderen for at reservere tid. Estimatet gælder din del af arbejdet.</p>
<p id="deskSelection" class="desk-selection" aria-live="polite">Vælg en opgave eller en aftale for at se sammenhængen.</p>
</section>
<aside class="desk-calendar-panel"><header><h2>Min kalender</h2><span class="small text-muted">Træk aftaler for at flytte dem. Træk nederst for at ændre varigheden.</span></header><p id="deskWorkHours" class="small text-muted px-3 mb-2"></p><div id="deskCalendar"></div><div class="desk-calendar-key"><span>● Allokeret</span><span>◷ Registreret arbejde</span></div><div id="deskMeetings"></div></aside>
</div>
<nav class="desk-links" aria-label="Flere planlægningsvisninger"><a href="/planning?view=work">Fuld kalender</a><a href="/planning/absence">Fravær og rapport</a></nav>
<dialog id="deskDialog"><form id="deskForm"><h2 id="deskDialogTitle"></h2><div id="deskFields"></div><p id="deskFormError" class="text-danger" role="alert"></p><div class="desk-actions justify-content-end mt-3"><button type="button" class="btn btn-outline-secondary" id="deskClose">Annullér</button><button type="submit" class="btn btn-primary" id="deskSave">Gem</button></div></form></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/js/work-desk.js?v=20261101-2"></script>{% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,123 @@
{% 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);display:flex;align-items:center;gap:.4rem}
.work-tabs .nav-link span{font-size:.75rem;opacity:.78}
.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}.work-tabs .nav-link{flex:1 1 42%;justify-content:center;text-align:center}.work-tabs .nav-link span{display:none}.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">
<a class="nav-link work-tab-link" href="/planning?view=queue"><i class="bi bi-person-workspace" aria-hidden="true"></i> Mit arbejde <span>Min dag og min kø</span></a>
<button class="nav-link" data-work-tab="tasks"><i class="bi bi-list-task" aria-hidden="true"></i> Opgaver <span>Fordel og planlæg</span></button>
<button class="nav-link" data-work-tab="team"><i class="bi bi-people" aria-hidden="true"></i> Team og kapacitet <span>Se belastning og ledig tid</span></button>
</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

@ -0,0 +1 @@
"""Restricted mobile remote control for BMC Hub."""

View File

@ -0,0 +1,6 @@
<!doctype html><html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hub · Mine fjernbetjeninger</title><link rel="stylesheet" href="/static/css/remote.css?v=3"></head>
<body class="remote-manage"><main><header class="app-header"><div class="brand"><span class="brand-mark" aria-hidden="true">H<span>•</span></span><div><span class="eyebrow">BMC HUB</span><h1>Mine fjernbetjeninger</h1></div></div><a class="back-link" href="/">Til Hub ↗</a></header><p id="notice" role="alert"></p>
<section><h2>Din daglige PIN</h2><p>Samme PIN bruges på alle dine enheder. Ændring kræver ny oplåsning på enhederne.</p><form id="pin-form"><label>4 cifre<input id="pin" type="password" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="off" required></label><button>Gem PIN</button></form></section>
<section><h2>Opret fjernbetjening</h2><form id="create-form"><label>Navn<input id="name" maxlength="100" required placeholder="Fx arbejdsmobil"></label><button>Opret og vis kode</button></form><div id="new-code" hidden><p>Indtast denne kode og din PIN på mobilen på <a href="/remote">/remote</a>. Koden vises kun her nu.</p><strong id="code" class="clock"></strong><button id="hide-code" class="secondary">Skjul kode</button></div></section>
<section><h2>Fjernbetjeninger</h2><div id="remotes"></div></section><section><h2>Forbundne enheder</h2><div id="devices"></div></section>
</main><script type="module" src="/static/js/remote-manage.js"></script></body></html>

View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#f3f6f6"><meta name="apple-mobile-web-app-capable" content="yes">
<title>Hub · Fjernbetjening</title><link rel="manifest" href="/remote/manifest.webmanifest"><link rel="stylesheet" href="/static/css/remote.css?v=20261014-day"></head>
<body class="remote-mobile"><svg class="icon-defs" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3 2"/></symbol><symbol id="i-cases" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="15" rx="3"/><path d="M9 5V3h6v2M8 10h8M8 14h5"/></symbol><symbol id="i-phone" viewBox="0 0 24 24"><path d="M7 3H4a1 1 0 0 0-1 1c0 9.4 7.6 17 17 17a1 1 0 0 0 1-1v-3l-5-2-2 2a13 13 0 0 1-7-7l2-2-2-5Z"/></symbol><symbol id="i-message" viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 1-8 8H4l1.5-4A8 8 0 1 1 20 11Z"/><path d="M8 10h8M8 14h5"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-play" viewBox="0 0 24 24"><path d="m9 5 11 7-11 7V5Z"/></symbol><symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14"/></symbol><symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol><symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-5-5 5 5-5 5"/></symbol></defs></svg><main><header class="app-header"><div class="brand"><span class="brand-mark" aria-hidden="true">H<span>•</span></span><div><span class="eyebrow">BMC HUB</span><h1>Hub Remote<span class="brand-subtitle">Din arbejdsdag. Lige ved hånden.</span></h1></div></div><span id="connection" role="status">Forbinder…</span></header>
<p id="notice" role="alert"></p>
<div id="uncertain" hidden><p>Svaret på den sidste handling mangler. Kontrollér resultatet, før du sender mere.</p><button id="check-command">Kontrollér resultat</button><button id="retry-command">Prøv samme handling igen</button></div>
<section id="login" hidden><div class="login-emblem"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg></div><span class="eyebrow">DIN HUB. LIGE VED HÅNDEN.</span><h2 id="login-title">Forbind din enhed</h2><p class="login-intro">Alt det vigtige fra Hub. Klar på din mobil.</p><form id="login-form">
<label id="code-label">12-cifret kode<input id="code" inputmode="numeric" autocomplete="off" pattern="[0-9]{12}" maxlength="12"></label>
<label>Din 4-cifrede PIN<input id="pin" type="password" inputmode="numeric" autocomplete="off" pattern="[0-9]{4}" maxlength="4" required></label>
<label id="device-label">Enhedens navn<input id="device" maxlength="100" value="Mobil / tablet"></label>
<button type="submit" id="login-submit">Forbind</button></form></section>
<div id="controls" hidden><div class="workspace-heading"><div><span class="eyebrow">DIT ARBEJDSRUM</span><h2>Min dag</h2></div><span id="remote-date"></span></div>
<section class="day-card" id="day-panel"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg></span><h2>Min dag · planlagt</h2></div><div id="day-items">Henter dagens plan…</div><p id="day-status" role="status"></p><button type="button" id="screen-awake" class="secondary">Hold skærmen tændt</button><small id="screen-awake-status" role="status"></small></section>
<section class="hero" id="timer-panel"><div class="section-top"><span class="eyebrow">AKTIV SAG</span><span id="desktop">Computer ikke forbundet</span></div><h2 id="active-case">Vælg en sag</h2><p id="deadline">Ingen deadline</p>
<div class="timer-dial"><div class="timer-face"><span class="timer-caption"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg> DIN TIMER</span><div id="elapsed" class="clock">00:00:00</div><p id="timer-case">Ingen timer kører</p></div></div>
<div class="buttons"><button data-action="start"><span class="control-icon"><svg class="icon" aria-hidden="true"><use href="#i-play"/></svg></span>Start timer</button><button data-action="pause"><span class="control-icon"><svg class="icon" aria-hidden="true"><use href="#i-pause"/></svg></span>Pause</button><button data-action="stop"><span class="control-icon"><svg class="icon" aria-hidden="true"><use href="#i-stop"/></svg></span>Stop og gem tid</button></div>
<label>Beskrivelse<textarea id="description" maxlength="2000" rows="2" placeholder="Hvad har du lavet?"></textarea></label>
<div id="paused"></div></section>
<section class="manual-card"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-plus"/></svg></span><h2>Registrer tid</h2></div><form id="manual-form"><label>Minutter på den valgte sag<input id="minutes" type="number" min="1" max="1440" required></label><button type="submit">Gem tid med beskrivelsen ovenfor</button></form></section>
<section class="cases-card" id="cases-panel"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg></span><h2>Sager</h2><span class="section-hint">Find din næste opgave</span></div><nav id="case-tabs"><button data-kind="new" aria-pressed="true">Nye</button><button data-kind="own" aria-pressed="false">Mine</button><button data-kind="search" aria-pressed="false">Søg</button></nav>
<form id="search-form"><label>Sagsnummer, titel eller kunde<input id="search" type="search" maxlength="100" placeholder="Søg efter sag eller kunde…"></label><button>Søg</button></form><div id="cases"></div><button id="more" hidden>Vis flere</button></section>
<section class="today-card"><div><span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg> DAGENS OVERBLIK</span><h2>Registreret i dag</h2></div><p id="today"></p></section>
<section class="phone-card" id="phone-card" hidden><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-phone"/></svg></span><h2>Kontakter &amp; opkald</h2><span id="call-count" class="count-badge" hidden></span></div>
<p class="section-hint">Ring via din VoIP-telefon i Hub</p><div class="call-heading"><span class="live-dot"></span><h3>Indgående opkald</h3><span class="section-hint">Live fra Hub</span></div><div id="incoming-calls" role="status">Henter opkald…</div>
<form id="contact-search-form"><label>Navn, firma, telefon eller e-mail<input id="contact-search" type="search" maxlength="100" placeholder="Find en kontakt…"></label><button>Søg kontakter</button></form><div id="contact-results"></div><button id="contact-more" hidden>Flere kontakter</button>
<p id="phone-notice" role="status"></p><button id="check-call" hidden>Kontrollér opkaldets status</button><button id="clear-call" hidden>Jeg har kontrolleret telefonen</button></section>
<section class="messages-card" id="messages-panel"><div class="section-heading"><span class="section-icon"><svg class="icon" aria-hidden="true"><use href="#i-message"/></svg></span><h2>Hub-beskeder</h2><span id="message-count" class="count-badge" hidden></span></div><div id="messages"></div><button data-action="read">Markér almindelige beskeder som læst</button></section>
<nav class="remote-nav" aria-label="Fjernbetjeningens navigation"><a href="#day-panel" aria-current="location"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg><span>Min dag</span></a><a href="#timer-panel"><svg class="icon" aria-hidden="true"><use href="#i-clock"/></svg><span>Timer</span></a><a href="#cases-panel"><svg class="icon" aria-hidden="true"><use href="#i-cases"/></svg><span>Sager</span></a><a href="#phone-card"><svg class="icon" aria-hidden="true"><use href="#i-phone"/></svg><span>Kontakter</span></a><a href="#messages-panel"><svg class="icon" aria-hidden="true"><use href="#i-message"/></svg><span>Beskeder</span></a></nav><footer class="remote-footer"><span>BMC Hub · Fjernbetjening</span><button id="logout" class="secondary">Afbryd denne enhed</button></footer></div>
<dialog id="switch-dialog"><h2>Skal timeren også skifte?</h2><p>Den nuværende timer pauses, hvis du skifter timer.</p><div class="buttons"><button data-switch="view">Kun skift sag</button><button data-switch="timer">Skift også timer</button><button data-switch="cancel" class="secondary">Annuller</button></div></dialog>
</main><script type="module" src="/static/js/remote-mobile.js?v=20261014-day"></script></body></html>

View File

@ -0,0 +1,298 @@
"""Remote API and mobile/administration pages in the existing Hub."""
import re
import secrets
from pathlib import Path
from uuid import UUID, uuid4
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field, ConfigDict
from app.core.auth_dependencies import get_current_user
from app.core.auth_service import AuthService
from app.core.database import atomic_transaction, execute_query, execute_query_single
from . import service
from .security import COOKIE, CSRF_COOKIE, guard, today, digest, lookup, new_code, reserve_attempt, remote_device, actor
router = APIRouter(prefix='/api/v1/remote', dependencies=[Depends(guard)])
views = APIRouter(dependencies=[Depends(guard)])
ROOT = Path(__file__).parent
class Credentials(BaseModel):
code: str = Field(default='', max_length=200)
pin: str = Field(max_length=200)
name: str = Field(default='Mobil / tablet', min_length=1, max_length=100)
class Pin(BaseModel):
pin: str = Field(pattern=r'^[0-9]{4}$')
class Name(BaseModel):
name: str = Field(min_length=1, max_length=100)
class Command(BaseModel):
model_config = ConfigDict(extra='forbid')
request_id: UUID
version: int = Field(ge=0)
timer_version: str = Field(pattern=r'^[0-9a-f]{64}$')
action: Literal['select', 'start', 'pause', 'resume', 'stop', 'manual', 'read', 'ack']
case_id: int | None = Field(default=None, gt=0)
time_id: int | None = Field(default=None, gt=0)
description: str = Field(default='', max_length=2000)
minutes: int | None = Field(default=None, ge=1, le=1440)
switch_timer: bool | None = None
partner_user_id: int | None = Field(default=None, gt=0)
message_id: int | None = Field(default=None, gt=0)
class Follow(BaseModel):
tab_id: UUID
enable: bool = False
disable: bool = False
after: int = Field(default=0, ge=0)
class PhoneCall(BaseModel):
model_config = ConfigDict(extra='forbid')
request_id: UUID
contact_id: int = Field(gt=0)
field: Literal['phone', 'mobile']
@router.get('/contacts')
async def remote_contacts(request: Request, q: str = Query('', max_length=100), offset: int = Query(0, ge=0)):
from . import telephony
return await telephony.contacts(actor(remote_device(request)), q, offset)
@router.get('/day')
def remote_day(request: Request):
return service.planned_day(actor(remote_device(request)))
@router.get('/telephony')
def remote_telephony(request: Request):
from . import telephony
return telephony.status(actor(remote_device(request)))
@router.get('/call/{request_id}')
def remote_call_result(request_id: UUID, request: Request):
from . import telephony
user = actor(remote_device(request)); telephony.require_access(user)
row = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id)))
if not row or row['payload'].get('action') != 'call':
return {'status': 'unknown', 'message': 'Hub har ikke registreret opkaldet. Kontrollér telefonen før et nyt opkald.'}
return row['result']
@router.post('/call')
async def remote_call(payload: PhoneCall, request: Request):
from . import telephony
return await telephony.call(payload, request)
def authenticated(request: Request):
return actor(remote_device(request))
def lock_user(uid):
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
@router.get('/bootstrap')
def bootstrap(request: Request, response: Response):
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(32)
response.set_cookie(CSRF_COOKIE, csrf, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/', max_age=30*86400)
status = 'connect'
try:
device = remote_device(request, unlocked=False)
status = 'ready' if device['unlocked_date'] == today() and device['pin_generation'] == device['generation'] else 'locked'
except HTTPException:
pass
return {'csrf': csrf, 'status': status}
@router.post('/connect')
def connect(payload: Credentials, request: Request, response: Response):
remote = execute_query_single('SELECT * FROM remote_controls WHERE code_lookup=%s', (lookup(payload.code),))
reserve_attempt(request.client.host if request.client else 'unknown', (remote or {}).get('user_id'))
if not re.fullmatch(r'[0-9]{12}', payload.code) or not re.fullmatch(r'[0-9]{4}', payload.pin) or not remote:
raise HTTPException(401, 'Forkert kode eller PIN')
with atomic_transaction():
lock_user(remote['user_id'])
remote = execute_query_single('SELECT r.*,u.is_active FROM remote_controls r JOIN users u ON u.user_id=r.user_id WHERE r.id=%s AND NOT r.revoked', (str(remote['id']),))
pin = execute_query_single('SELECT * FROM remote_pins WHERE user_id=%s', (remote['user_id'],)) if remote else None
if not remote or not remote['is_active'] or not pin or not AuthService.verify_password(payload.code, remote['code_hash']) or not AuthService.verify_password(lookup('pin:' + payload.pin), pin['pin_hash']):
raise HTTPException(401, 'Forkert kode eller PIN')
token = secrets.token_urlsafe(32)
execute_query('''INSERT INTO remote_devices(id,remote_id,name,token_hash,expires_at,unlocked_date,pin_generation)
VALUES(%s,%s,%s,%s,NOW()+INTERVAL '30 days',%s,%s)''',
(str(uuid4()), str(remote['id']), payload.name.strip() or 'Mobil / tablet', digest(token), today(), pin['generation']))
response.set_cookie(COOKIE, token, secure=request.url.scheme == 'https', httponly=True, samesite='strict', path='/api/v1/remote', max_age=30*86400)
return {'ok': True}
@router.post('/unlock')
def unlock(payload: Credentials, request: Request):
device = remote_device(request, unlocked=False)
reserve_attempt(request.client.host if request.client else 'unknown', device['user_id'], device['id'])
with atomic_transaction():
lock_user(device['user_id'])
device = remote_device(request, unlocked=False)
if not re.fullmatch(r'[0-9]{4}', payload.pin) or not AuthService.verify_password(lookup('pin:' + payload.pin), device['pin_hash']):
raise HTTPException(401, 'Forkert PIN')
execute_query('UPDATE remote_devices SET unlocked_date=%s,pin_generation=%s,last_seen_at=NOW() WHERE id=%s', (today(), device['generation'], str(device['id'])))
return {'ok': True}
@router.post('/logout')
def logout(request: Request, response: Response):
device = remote_device(request, unlocked=False)
with atomic_transaction():
lock_user(device['user_id'])
execute_query('UPDATE remote_devices SET revoked=TRUE WHERE id=%s', (str(device['id']),))
response.delete_cookie(COOKIE, path='/api/v1/remote', secure=request.url.scheme == 'https', httponly=True, samesite='strict')
return {'ok': True}
@router.get('/state')
def get_state(request: Request):
device = remote_device(request)
with atomic_transaction():
lock_user(device['user_id'])
device = remote_device(request)
execute_query('UPDATE remote_devices SET last_seen_at=NOW() WHERE id=%s', (str(device['id']),))
return service.state(actor(device))
@router.get('/cases')
def list_cases(kind: Literal['new', 'own', 'search'] = 'search', q: str = Query('', max_length=100),
offset: int = Query(0, ge=0), user=Depends(authenticated)):
return service.cases(user, kind, q, offset)
@router.post('/command')
async def run_command(payload: Command, request: Request, user=Depends(authenticated)):
return await service.command(payload, user, request=request)
@router.get('/command-result/{request_id}')
def command_result(request_id: UUID, user=Depends(authenticated)):
result = execute_query_single('SELECT result FROM remote_commands WHERE user_id=%s AND request_id=%s', (user['id'], str(request_id)))
return {'completed': bool(result), 'result': result['result'] if result else None}
@router.get('/admin')
def admin(user=Depends(get_current_user)):
uid = user['id']
remotes = execute_query('SELECT id,name,revoked,created_at FROM remote_controls WHERE user_id=%s ORDER BY created_at DESC', (uid,))
devices = execute_query('''SELECT d.id,d.remote_id,d.name,d.expires_at,d.revoked,d.last_seen_at FROM remote_devices d
JOIN remote_controls r ON r.id=d.remote_id WHERE r.user_id=%s ORDER BY d.last_seen_at DESC''', (uid,))
return {'remotes': remotes, 'devices': devices, 'pin_set': bool(execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)))}
@router.put('/admin/pin')
def set_pin(payload: Pin, user=Depends(get_current_user)):
hashed = AuthService.hash_password(lookup('pin:' + payload.pin))
with atomic_transaction():
lock_user(user['id'])
execute_query('''INSERT INTO remote_pins(user_id,pin_hash) VALUES(%s,%s)
ON CONFLICT(user_id) DO UPDATE SET pin_hash=EXCLUDED.pin_hash,generation=remote_pins.generation+1''', (user['id'], hashed))
return {'ok': True}
def create_code(uid, name, remote_id=None):
with atomic_transaction():
lock_user(uid)
if remote_id and not execute_query_single('SELECT id FROM remote_controls WHERE id=%s AND user_id=%s AND NOT revoked', (str(remote_id), uid)):
raise HTTPException(404, 'Fjernbetjeningen findes ikke')
if not execute_query_single('SELECT user_id FROM remote_pins WHERE user_id=%s', (uid,)):
raise HTTPException(409, 'Opret din PIN først')
# A global advisory lock prevents even extremely unlikely code collisions.
execute_query('SELECT pg_advisory_xact_lock(1069,1)')
code = new_code()
while execute_query_single('SELECT id FROM remote_controls WHERE code_lookup=%s', (lookup(code),)):
code = new_code()
if remote_id:
execute_query('UPDATE remote_controls SET code_lookup=%s,code_hash=%s WHERE id=%s', (lookup(code), AuthService.hash_password(code), str(remote_id)))
execute_query('UPDATE remote_devices SET revoked=TRUE WHERE remote_id=%s', (str(remote_id),))
else:
remote_id = uuid4()
execute_query('INSERT INTO remote_controls(id,user_id,name,code_lookup,code_hash) VALUES(%s,%s,%s,%s,%s)', (str(remote_id), uid, name, lookup(code), AuthService.hash_password(code)))
return {'id': str(remote_id), 'code': code}
@router.post('/admin/remotes')
def create_remote(payload: Name, user=Depends(get_current_user)):
return create_code(user['id'], payload.name.strip() or 'Fjernbetjening')
@router.post('/admin/remotes/{remote_id}/rotate')
def rotate_remote(remote_id: UUID, user=Depends(get_current_user)):
return create_code(user['id'], '', remote_id)
@router.delete('/admin/remotes/{remote_id}')
def revoke_remote(remote_id: UUID, user=Depends(get_current_user)):
with atomic_transaction():
lock_user(user['id'])
rows = execute_query('UPDATE remote_controls SET revoked=TRUE WHERE id=%s AND user_id=%s RETURNING id', (str(remote_id), user['id']))
if not rows:
raise HTTPException(404, 'Fjernbetjeningen findes ikke')
return {'ok': True}
@router.delete('/admin/devices/{device_id}')
def revoke_device(device_id: UUID, user=Depends(get_current_user)):
with atomic_transaction():
lock_user(user['id'])
rows = execute_query('''UPDATE remote_devices d SET revoked=TRUE FROM remote_controls r
WHERE d.remote_id=r.id AND d.id=%s AND r.user_id=%s RETURNING d.id''', (str(device_id), user['id']))
if not rows:
raise HTTPException(404, 'Enheden findes ikke')
return {'ok': True}
@router.post('/follow')
def follow(payload: Follow, user=Depends(get_current_user)):
with atomic_transaction():
lock_user(user['id'])
s = service.user_state(user['id'])
if payload.enable:
execute_query('''UPDATE remote_user_state SET follower_id=%s,follower_seen_at=NOW(),
selection_target=NULL,selection_expires_at=NULL WHERE user_id=%s''', (str(payload.tab_id), user['id']))
return {'following': True, 'sequence': s['selection_seq']}
if str(s['follower_id']) != str(payload.tab_id):
return {'following': False, 'sequence': s['selection_seq']}
if payload.disable:
execute_query('UPDATE remote_user_state SET follower_id=NULL,follower_seen_at=NULL,selection_target=NULL WHERE user_id=%s', (user['id'],))
return {'following': False, 'sequence': s['selection_seq']}
# A missed heartbeat must not deliver a historical selection on reconnect.
connected = bool(s['connected'])
execute_query('UPDATE remote_user_state SET follower_seen_at=NOW() WHERE user_id=%s', (user['id'],))
event = None
if connected and str(s['selection_target']) == str(payload.tab_id) and s['selection_seq'] > payload.after and s['selection_expires_at'] and s['selection_expires_at'] > service.datetime.now(service.timezone.utc):
service.case(s['active_case_id'], user)
event = {'case_id': s['active_case_id']}
return {'following': True, 'sequence': s['selection_seq'], 'selection': event}
@views.get('/remote', response_class=HTMLResponse)
def remote_page():
return HTMLResponse((ROOT / 'mobile.html').read_text(), headers={'Cache-Control': 'no-store'})
@views.get('/remote/manage', response_class=HTMLResponse)
def manage_page(user=Depends(get_current_user)):
return HTMLResponse((ROOT / 'manage.html').read_text(), headers={'Cache-Control': 'no-store'})
@views.get('/remote/manifest.webmanifest')
def manifest():
from fastapi.responses import JSONResponse
return JSONResponse({'name': 'BMC Hub Fjernbetjening', 'short_name': 'Hub Remote', 'start_url': '/remote',
'display': 'standalone', 'theme_color': '#0f4c75', 'background_color': '#f4f6f8'}, media_type='application/manifest+json')

View File

@ -0,0 +1,92 @@
"""Remote credentials are deliberately separate from normal Hub authentication."""
import hashlib
import hmac
import math
import secrets
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from fastapi import HTTPException, Request, Response
from app.core.config import settings
from app.core.auth_service import AuthService
from app.core.database import atomic_transaction, execute_query, execute_query_single
COOKIE = 'hub_remote_device'
CSRF_COOKIE = 'hub_remote_csrf'
def today(now=None):
return (now or datetime.now(timezone.utc)).astimezone(ZoneInfo('Europe/Copenhagen')).date()
def digest(value):
return hashlib.sha256(value.encode()).hexdigest()
def lookup(value):
return hmac.new(settings.REMOTE_LOOKUP_KEY.encode(), value.encode(), hashlib.sha256).hexdigest()
def new_code():
return f'{secrets.randbelow(10**12):012d}'
def guard(request: Request, response: Response):
response.headers['Cache-Control'] = 'no-store'
if not settings.REMOTE_ENABLED:
raise HTTPException(404, 'Fjernbetjening er slået fra')
if len(settings.REMOTE_LOOKUP_KEY) < 32:
raise HTTPException(503, 'Fjernbetjening mangler sikker serverkonfiguration')
if request.method not in {'GET', 'HEAD', 'OPTIONS'}:
origin = str(request.base_url).rstrip('/')
if request.headers.get('origin') != origin:
raise HTTPException(403, 'Ugyldig oprindelse')
cookie = request.cookies.get(CSRF_COOKIE, '')
token = request.headers.get('x-remote-csrf', '')
if not cookie or not hmac.compare_digest(cookie, token):
raise HTTPException(403, 'Opdatér siden og prøv igen')
def reserve_attempt(ip, user_id=None, device_id=None):
"""Commit even failed attempts, and serialize all buckets across worker processes."""
buckets = sorted({lookup('ip:' + ip), *([lookup('user:' + str(user_id))] if user_id else []),
*([lookup('device:' + str(device_id))] if device_id else [])})
blocked = None
with atomic_transaction():
for bucket in buckets:
execute_query("INSERT INTO remote_attempts(bucket,next_allowed_at) VALUES(%s,'epoch') ON CONFLICT DO NOTHING", (bucket,))
rows = execute_query('SELECT next_allowed_at FROM remote_attempts WHERE bucket=ANY(%s) ORDER BY bucket FOR UPDATE', (buckets,))
now = execute_query_single('SELECT clock_timestamp() AS now')['now']
wait = max([(row['next_allowed_at'] - now).total_seconds() for row in rows] + [0])
if wait > 0:
blocked = max(1, math.ceil(wait))
else:
execute_query("UPDATE remote_attempts SET next_allowed_at=clock_timestamp()+INTERVAL '30 seconds' WHERE bucket=ANY(%s)", (buckets,))
if blocked:
raise HTTPException(429, 'Vent før næste loginforsøg', headers={'Retry-After': str(blocked)})
def remote_device(request, unlocked=True):
token = request.cookies.get(COOKIE)
if not token:
raise HTTPException(401, 'Forbind enheden med din kode')
row = execute_query_single('''SELECT d.*,r.user_id,p.generation,p.pin_hash,u.username,u.full_name,u.is_superadmin
FROM remote_devices d JOIN remote_controls r ON r.id=d.remote_id
JOIN remote_pins p ON p.user_id=r.user_id JOIN users u ON u.user_id=r.user_id
WHERE d.token_hash=%s AND NOT d.revoked AND NOT r.revoked AND d.expires_at>NOW() AND u.is_active=TRUE''', (digest(token),))
if not row:
raise HTTPException(401, 'Forbindelsen er udløbet eller tilbagekaldt')
if unlocked and (row['unlocked_date'] != today() or row['pin_generation'] != row['generation']):
raise HTTPException(423, 'Indtast dagens PIN')
return row
def actor(device):
# Current permissions are fetched for every request; no cached admin JWT is minted.
return {'id': device['user_id'], 'username': device['username'], 'full_name': device['full_name'],
'is_superadmin': device['is_superadmin'], 'permissions': AuthService.get_user_permissions(device['user_id'])}
def case_permission(user):
if not user.get('is_superadmin') and not {'cases.view', 'tickets.view'}.intersection(user.get('permissions') or []):
raise HTTPException(403, 'Du har ikke adgang til sager')

View File

@ -0,0 +1,195 @@
"""Restricted remote actions reuse existing time and messaging business logic."""
from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from psycopg2.extras import Json
from app.core.database import atomic_transaction, execute_query, execute_query_single
from app.modules.bottom_bar.backend.service import get_own_timer_snapshot, get_user_messages_summary, mark_user_messages_read, acknowledge_message
from app.modules.sag.backend.create_support import closed_statuses
from .security import case_permission, today
def user_state(user_id):
execute_query('INSERT INTO remote_user_state(user_id) VALUES(%s) ON CONFLICT DO NOTHING', (user_id,))
return execute_query_single('SELECT *,follower_seen_at>NOW()-INTERVAL \'10 seconds\' AS connected FROM remote_user_state WHERE user_id=%s', (user_id,))
def case(case_id, user):
case_permission(user)
row = execute_query_single('SELECT id,titel,status,deadline,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL', (case_id,))
if not row:
raise HTTPException(404, 'Sagen findes ikke længere')
return case_display(row)
def case_display(row, now=None):
row = dict(row)
deadline = row.get('deadline')
if deadline:
# Existing case timestamps without offset use Hub's local Danish timezone.
from zoneinfo import ZoneInfo
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=ZoneInfo('Europe/Copenhagen'))
seconds = int((deadline - (now or datetime.now(timezone.utc))).total_seconds())
row.update(deadline=deadline.isoformat(), deadline_seconds=seconds,
deadline_state='overdue' if seconds < 0 else 'warning' if seconds <= 3600 else 'normal')
else:
row.update(deadline_seconds=None, deadline_state='none')
return row
def cases(user, kind='search', q='', offset=0):
case_permission(user)
where = 's.deleted_at IS NULL'
params = []
if kind == 'new':
where += " AND LOWER(TRIM(s.status))='ny'"
elif kind == 'own':
where += ' AND s.ansvarlig_bruger_id=%s AND NOT (LOWER(TRIM(s.status))=ANY(%s))'
params.extend([user['id'], closed_statuses()])
if q:
where += ' AND (s.titel ILIKE %s OR s.id::text=%s OR c.name ILIKE %s)'
params.extend(['%' + q + '%', q, '%' + q + '%'])
rows = execute_query(f'''SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name FROM sag_sager s
LEFT JOIN customers c ON c.id=s.customer_id WHERE {where}
ORDER BY s.deadline ASC NULLS LAST,s.created_at DESC,s.id DESC LIMIT 51 OFFSET %s''', tuple(params + [offset]))
return {'items': [case_display(row) for row in rows[:50]], 'has_more': len(rows) > 50}
def timer_version(user_id):
"""Fingerprint changes made by desktop as well as remote, excluding elapsed seconds."""
from .security import digest
rows = execute_query('''SELECT id,sag_id,aktiv_timer,start_tid,slut_tid,paused_at,pause_total_seconds,updated_at
FROM tmodule_times WHERE medarbejder_id=%s AND slut_tid IS NULL
AND (aktiv_timer=TRUE OR paused_at IS NOT NULL) ORDER BY id''', (user_id,))
latest = execute_query_single('''SELECT id,updated_at FROM tmodule_times WHERE medarbejder_id=%s
ORDER BY updated_at DESC NULLS LAST,id DESC LIMIT 1''', (user_id,))
import json
return digest(json.dumps(jsonable_encoder([rows, latest]), sort_keys=True))
def planned_day(user, day=None):
from datetime import timedelta
from zoneinfo import ZoneInfo
case_permission(user)
day = day or today()
zone = ZoneInfo('Europe/Copenhagen')
start = datetime.combine(day, datetime.min.time(), tzinfo=zone)
end = datetime.combine(day + timedelta(days=1), datetime.min.time(), tzinfo=zone)
rows = execute_query("""SELECT a.id, a.sag_id AS case_id, a.starts_at, a.ends_at,
s.titel AS title, c.name AS customer_name, 'case' AS kind
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id
LEFT JOIN customers c ON c.id=s.customer_id
WHERE a.user_id=%s AND a.deleted_at IS NULL AND s.deleted_at IS NULL
AND a.starts_at<%s AND a.ends_at>%s
UNION ALL
SELECT q.id, NULL AS case_id, q.starts_at, q.ends_at, q.description AS title,
COALESCE(c.name,'Intern tid') AS customer_name, 'standalone' AS kind
FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id
WHERE q.user_id=%s AND q.state='planned' AND q.starts_at<%s AND q.ends_at>%s
ORDER BY starts_at""", (user['id'], end, start, user['id'], end, start)) or []
return {'date': day.isoformat(), 'items': rows}
def state(user):
s = user_state(user['id'])
selected = None
if s['active_case_id']:
try:
selected = case(s['active_case_id'], user)
except HTTPException as exc:
if exc.status_code != 404:
raise
total = execute_query_single('''SELECT COALESCE(SUM(faktisk_tid_min),0)::int AS minutes
FROM tmodule_times WHERE medarbejder_id=%s AND worked_date=%s
AND NOT aktiv_timer AND paused_at IS NULL''', (user['id'], today()))
return {'version': s['version'], 'timer_version': timer_version(user['id']), 'active_case': selected,
'timer': get_own_timer_snapshot(user['id']), 'today_minutes': total['minutes'],
'desktop_connected': bool(s['connected']), 'messages': get_user_messages_summary(user['id']),
'server_time': datetime.now(timezone.utc).isoformat(), 'user_id': user['id'],
'user_name': user['full_name'] or user['username']}
async def command(payload, user, request=None):
"""Atomic idempotency + remote version + user lock shared with desktop timer writes."""
import importlib
timers = importlib.import_module('app.timetracking.backend.router')
uid = user['id']
value = payload.model_dump(mode='json')
with atomic_transaction():
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
if request is not None:
from .security import remote_device, actor
user = actor(remote_device(request))
s = user_state(uid)
old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id)))
if old:
if old['payload'] != value:
raise HTTPException(409, 'Handlingen har allerede andre oplysninger')
return old['result']
if payload.version != s['version'] or payload.timer_version != timer_version(uid):
raise HTTPException(409, 'Hub er ændret. Opdatér og prøv igen')
action = payload.action
result = {'ok': True}
if action in {'select', 'start', 'manual'}:
if not payload.case_id:
raise HTTPException(400, 'Vælg en sag')
case(payload.case_id, user)
timer_data = {'sag_id': payload.case_id, 'beskrivelse': payload.description,
'billing_mode': 'automatic'}
if action == 'select':
active = get_own_timer_snapshot(uid)['active']
if active.get('active') and active.get('sag_id') != payload.case_id:
if payload.switch_timer is None:
raise HTTPException(409, 'Vælg om timeren også skal skifte')
if payload.switch_timer:
await timers.pause_live_timer_v1(current_user=user)
paused = execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s
AND sag_id=%s AND NOT aktiv_timer AND paused_at IS NOT NULL AND slut_tid IS NULL
ORDER BY paused_at DESC LIMIT 1''', (uid, payload.case_id))
if paused:
await timers.resume_live_timer_v1(payload={'time_id': paused['id']}, current_user=user)
else:
await timers.start_live_timer_v1(payload=timer_data, current_user=user)
execute_query('''UPDATE remote_user_state SET active_case_id=%s,selection_seq=selection_seq+1,
selection_target=CASE WHEN follower_seen_at>NOW()-INTERVAL '10 seconds' THEN follower_id ELSE NULL END,
selection_expires_at=NOW()+INTERVAL '10 seconds' WHERE user_id=%s''', (payload.case_id, uid))
elif action == 'start':
active = get_own_timer_snapshot(uid)['active']
if active.get('active'):
raise HTTPException(409, 'En timer kører allerede. Brug sagsvalg for at skifte')
result = await timers.start_live_timer_v1(payload=timer_data, current_user=user)
elif action in {'pause', 'resume', 'stop'}:
if payload.time_id:
owned = execute_query_single('''SELECT id FROM tmodule_times WHERE id=%s AND medarbejder_id=%s
AND slut_tid IS NULL AND start_tid IS NOT NULL AND (aktiv_timer OR paused_at IS NOT NULL)''', (payload.time_id, uid))
if not owned:
raise HTTPException(404, 'Timeren findes ikke eller er afsluttet')
if action == 'pause':
result = await timers.pause_live_timer_v1(current_user=user)
elif action == 'resume':
result = await timers.resume_live_timer_v1(payload={'time_id': payload.time_id}, current_user=user)
else:
if not payload.description.strip():
raise HTTPException(400, 'Angiv beskrivelse før stop')
active = get_own_timer_snapshot(uid)['active']
tid = payload.time_id or active.get('time_entry_id')
if not tid:
raise HTTPException(404, 'Ingen timer valgt')
execute_query('UPDATE tmodule_times SET description=%s WHERE id=%s AND medarbejder_id=%s AND slut_tid IS NULL', (payload.description, tid, uid))
result = await timers.stop_live_timer_v1(payload={'time_id': tid}, current_user=user)
elif action == 'manual':
if not payload.description.strip() or not payload.minutes:
raise HTTPException(400, 'Angiv minutter og beskrivelse')
timer_data.update(faktisk_tid_min=payload.minutes, worked_date=today().isoformat())
result = await timers.create_manual_time_v1(payload=timer_data, current_user=user)
elif action == 'read':
result = {'updated': mark_user_messages_read(uid, partner_user_id=payload.partner_user_id)}
elif action == 'ack':
if not payload.message_id or not acknowledge_message(uid, payload.message_id):
raise HTTPException(404, 'Beskeden findes ikke eller er allerede bekræftet')
execute_query('UPDATE remote_user_state SET version=version+1 WHERE user_id=%s', (uid,))
result = jsonable_encoder({'ok': True, 'result': result, 'version': s['version'] + 1})
execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)', (uid, str(payload.request_id), Json(value), Json(result)))
return result

View File

@ -0,0 +1,87 @@
"""Contact lookup and restricted control of the owner's existing VoIP phone."""
from fastapi import HTTPException
from psycopg2.extras import Json
from app.core.database import atomic_transaction, execute_query, execute_query_single
from app.dashboard.backend.mission_service import MissionService
from .security import actor, remote_device
def permitted(user):
return bool(user.get('is_superadmin') or 'customers.view' in (user.get('permissions') or []))
def require_access(user):
if not permitted(user):
raise HTTPException(403, 'Du har ikke adgang til kontakter og telefoni')
async def contacts(user, q, offset):
require_access(user)
from app.contacts.backend.router_simple import get_contacts
data = await get_contacts(search=q, customer_id=None, is_active=True, limit=50, offset=offset)
fields = ('id', 'first_name', 'last_name', 'email', 'phone', 'mobile', 'company_names')
return {'items': [{key: row.get(key) for key in fields} for row in data['contacts']],
'has_more': offset + len(data['contacts']) < data['total']}
def status(user):
if not permitted(user):
return {'allowed': False, 'incoming': []}
# Match Mission Control's ring window without changing shared call state.
rows = MissionService.get_active_calls()
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc)
incoming = []
for row in rows:
updated = row.get('updated_at') or row.get('started_at')
if not updated:
continue
if updated.tzinfo is None:
updated = updated.replace(tzinfo=ZoneInfo('Europe/Copenhagen'))
if (now - updated).total_seconds() > MissionService.get_ring_timeout_seconds():
continue
incoming.append({key: row.get(key) for key in ('call_id', 'caller_number', 'contact_name', 'company_name', 'ringing_phones')})
return {'allowed': True, 'incoming': incoming}
async def call(payload, request):
"""Persist a reservation before external I/O; never automatically redial on uncertainty."""
from app.modules.telefoni.backend.router import click_to_call
from app.modules.telefoni.backend.schemas import TelefoniClickToCallRequest
user = actor(remote_device(request))
require_access(user)
uid = user['id']
value = {'action': 'call', **payload.model_dump(mode='json')}
with atomic_transaction():
execute_query('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE', (uid,))
user = actor(remote_device(request)); require_access(user)
old = execute_query_single('SELECT payload,result FROM remote_commands WHERE user_id=%s AND request_id=%s', (uid, str(payload.request_id)))
if old:
if old['payload'] != value:
raise HTTPException(409, 'Handlingen har allerede andre oplysninger')
return old['result']
contact = execute_query_single('SELECT id,phone,mobile FROM contacts WHERE id=%s AND is_active=TRUE', (payload.contact_id,))
if not contact or not contact.get(payload.field):
raise HTTPException(404, 'Kontakten har ikke det valgte telefonnummer')
# Only stored phone numbers can reach the existing action URL template.
import re
number = str(contact[payload.field]).strip()
if not re.fullmatch(r'\+?[0-9][0-9 () .-]{1,39}', number):
raise HTTPException(400, 'Kontaktens telefonnummer skal rettes i Hub')
pending = {'status': 'uncertain', 'message': 'Opkald er afsendt eller under behandling. Kontrollér telefonen før et nyt opkald.'}
execute_query('INSERT INTO remote_commands(user_id,request_id,payload,result) VALUES(%s,%s,%s,%s)',
(uid, str(payload.request_id), Json(value), Json(pending)))
request.state.user_id = uid
try:
await click_to_call(TelefoniClickToCallRequest(number=number, user_id=uid), request)
result = {'status': 'ok', 'message': 'Opkald startet på din VoIP-telefon'}
except HTTPException as exc:
# A timeout can mean that the phone received the command. Never redial it.
result = {'status': 'failed' if exc.status_code < 500 else 'uncertain',
'message': 'Opkald kunne ikke startes. Kontrollér telefonopsætningen i Hub.' if exc.status_code < 500 else pending['message']}
except Exception:
result = pending
execute_query('UPDATE remote_commands SET result=%s WHERE user_id=%s AND request_id=%s',
(Json(result), uid, str(payload.request_id)))
return result

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from app.services.invoice_fee_service import with_invoice_fee
import logging
import json
from datetime import date
@ -399,6 +401,7 @@ def _generate_due_invoices_for_customer(as_of: date, customer_id: int) -> Dict[s
if existing:
continue
lines = with_invoice_fee(lines, first["customer_id"], cursor=cursor, native=True)
cursor.execute(
"""
INSERT INTO ordre_drafts (
@ -547,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."""
@ -647,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)
@ -670,6 +695,7 @@ async def list_assets(
SELECT
ha.id,
ha.asset_type,
ha.bmc_asset_category,
ha.brand,
ha.model,
ha.serial_number,
@ -946,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": [
@ -1015,7 +1041,9 @@ async def quick_rent_hardware(hardware_id: int, payload: QuickRentCreateInput):
if not startup_lines:
raise HTTPException(status_code=400, detail="At least one startup/order line must have a price")
coverage_end = payload.start_date + relativedelta(months=payload.initial_operations_months)
startup_lines = with_invoice_fee(startup_lines, payload.customer_id)
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 (
@ -1046,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

@ -0,0 +1,263 @@
"""Atomic, opt-in V5 command batches. Existing case APIs are unchanged."""
import hashlib
import html
import json
import logging
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.encoders import jsonable_encoder
from psycopg2.extras import RealDictCursor
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.core.database import get_db_connection, release_db_connection
logger=logging.getLogger(__name__)
router = APIRouter()
SUPPORTED = {'time','assign','estimate','subcase','self','todo','waiting','solution','deadline','defer','relation','priority','group','note','mail','file','help','timer'}
def fail(message):
raise HTTPException(400, detail=message)
def text(command, key, required=True):
value = str(command.get(key) or '').strip()
if required and not value: fail(f'{key} skal udfyldes')
if len(value) > 10000: fail('Teksten er for lang')
return value
def resolve_user(cursor, token):
token = str(token or '').strip().casefold().lstrip('@')
cursor.execute('SELECT user_id, username, full_name FROM users WHERE is_active=true')
matches = []
for row in cursor.fetchall():
name = str(row.get('full_name') or '').strip()
initials = ''.join(p[0] for p in name.split()).casefold()
if token in {str(row['user_id']),str(row.get('username') or '').casefold(),name.casefold(),initials}:
matches.append(row)
if len(matches) != 1: fail('Medarbejderen findes ikke eller initialerne er tvetydige')
return matches[0]
def normalize(cursor, case, payload, actor):
from .router import _normalize_optional_timestamp, _validate_deferred_deadline
commands = payload.get('commands')
if not isinstance(commands, list) or len(commands) > 50: fail('Vælg højst 50 kommandoer')
if len(str(payload.get('comment') or '')) > 50000: fail('Kommentaren er for lang')
resolved, children, dates = [], set(), {'main':dict(case)}
for index, raw in enumerate(commands):
try:
if not isinstance(raw,dict): fail('Ugyldig kommando')
c = {key:value for key,value in raw.items() if key in {'type','scope','line','title','description','text','reason','assignee','minutes','category','settlement_choice','date','target','priority','group','case_id','caseId','target_id','action'}}; kind = c.get('type'); scope = c.get('scope','main')
if kind in {'mail','note','solution'}: c['description']=c.get('description') or c.get('text')
if kind=='waiting': c['description']=c.get('description') or c.get('reason')
if kind=='relation': c['case_id']=c.get('case_id') or c.get('caseId')
if kind not in SUPPORTED: fail('Ukendt kommando')
if kind != 'subcase' and scope != 'main' and (not isinstance(scope,int) or isinstance(scope,bool) or scope not in children): fail('// kræver en tidligere undersag')
if kind == 'subcase':
perms = set(actor.get('permissions') or [])
if not actor.get('is_superadmin') and not perms.intersection({'cases.create','tickets.create'}):
raise HTTPException(403, detail='Du må ikke oprette undersager')
if scope != 'main' and scope != len(children): fail('Ugyldig undersagsrækkefølge')
c['scope']='main'
title=text(c,'title')
if len(title)>255: fail('Undersagens titel må højst være 255 tegn')
child_index=len(children); children.add(child_index); c['child_index']=child_index
dates[child_index]={'deadline':None,'deferred_until':None,'assigned_group_id':dates['main'].get('assigned_group_id')}
if kind in {'assign','self'}:
if kind == 'assign': text(c,'reason')
else: c['reason']=str(c.get('reason') or 'Tildelt til mig via kommando')
user=resolve_user(cursor,actor['id'] if kind=='self' else text(c,'assignee'))
c['user_id']=user['user_id']; c['user_name']=user.get('full_name') or user['username']
cursor.execute('SELECT group_id FROM user_groups WHERE user_id=%s ORDER BY (group_id=%s) DESC, group_id', (c['user_id'],dates[scope].get('assigned_group_id')))
group=cursor.fetchone()
if not group: fail('Medarbejderen mangler en gruppe')
c['group_id']=group['group_id']; dates[scope]['assigned_group_id']=c['group_id']
if kind == 'group':
token=text(c,'group'); text(c,'reason')
cursor.execute('SELECT id,name FROM groups WHERE LOWER(name)=LOWER(%s)',(token,)); groups=cursor.fetchall()
if len(groups)!=1: fail('Gruppen findes ikke eller er tvetydig')
c['group_id']=groups[0]['id']; c['group_name']=groups[0]['name']; dates[scope]['assigned_group_id']=c['group_id']
if kind in {'time','estimate'}:
minutes=c.get('minutes')
if isinstance(minutes,bool) or not isinstance(minutes,int) or not (0 if kind=='estimate' else 1)<=minutes<=525600: fail('Ugyldigt minuttal')
if kind=='time':
text(c,'description'); text(c,'category',required=False); choice='draft' if payload.get('billing_mode')=='automatic' else str(c.get('settlement_choice') or 'draft')
from app.timetracking.backend.router import _case_billing_choice
c['billing']=_case_billing_choice(case['id'],choice)
c['settlement_choice']=choice
if kind=='todo':
text(c,'title')
if c.get('assignee'):
target=resolve_user(cursor,c['assignee']); c['user_name']=target.get('full_name') or target['username']; c['assigned_to_user_id']=target['user_id']
if c.get('date'):
try: date.fromisoformat(c['date'])
except (ValueError,TypeError): fail('Ugyldig tododato')
if kind in {'waiting','solution','note','mail'}: text(c,'description')
if kind=='waiting':
target=str(c.get('target') or 'kunde').lower()
if target not in {'kunde','leverandør','intern','customer','supplier'}: fail('Ukendt afventer-type')
c['target']=target
if kind=='priority':
value=str(c.get('priority') or '').lower()
mapping={'høj':'high','normal':'normal','lav':'low','kritisk':'urgent','high':'high','low':'low','critical':'urgent','urgent':'urgent'}
if value not in mapping: fail('Ukendt prioritet')
c['priority']=mapping[value]; text(c,'reason')
if kind in {'deadline','defer'}:
value=_normalize_optional_timestamp(text(c,'date'),'deadline' if kind=='deadline' else 'deferred_until')
dates[scope]['deadline' if kind=='deadline' else 'deferred_until']=value
if kind=='relation':
target=c.get('case_id') or c.get('target_id')
try: target=int(str(target).lstrip('#'))
except (ValueError,TypeError): fail('Ugyldigt sagsnummer')
if scope=='main' and target==case['id']: fail('Sagen kan ikke relateres til sig selv')
cursor.execute('SELECT id,titel,customer_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(target,)); relation=cursor.fetchone()
if not relation: fail('Den relaterede sag findes ikke')
c['case_id']=target; c['case_title']=relation['titel']; c['customer_id']=relation['customer_id']
if kind=='timer':
if c.get('action') not in {'start','pause'}: fail('Timer kræver start eller pause')
if c['action']=='start': text(c,'description')
resolved.append(c)
except HTTPException as error:
line = raw.get('line', index + 1) if isinstance(raw, dict) else index + 1
if not isinstance(line, int) or isinstance(line, bool) or line < 1:
line = index + 1
raise HTTPException(error.status_code, detail=f"Linje {line}: {error.detail}") from error
for values in dates.values():
_validate_deferred_deadline(values.get('deferred_until'),values.get('deadline'))
return resolved
def comment(cursor,case_id,author,content,internal=True,system=False):
cursor.execute('INSERT INTO sag_kommentarer (sag_id,forfatter,indhold,er_intern,er_system_besked) VALUES (%s,%s,%s,%s,%s) RETURNING id',(case_id,author,html.escape(content),internal,system))
return cursor.fetchone()['id']
def apply(cursor,case,commands,payload,actor):
result=[]; children={}; author=actor.get('full_name') or actor.get('username') or 'Bruger'
if str(payload.get('comment') or '').strip():
result.append({'type':'comment','case_id':case['id'],'id':comment(cursor,case['id'],author,str(payload['comment']),payload.get('internal',True) is not False)})
for c in commands:
kind=c['type']; cid=case['id'] if kind=='subcase' or c.get('scope','main')=='main' else children[c['scope']]; item={'type':kind,'case_id':cid}
if kind in {'file','help'}:
item['client_action']=c; result.append(item); continue
if kind=='subcase':
cursor.execute('SELECT assigned_group_id FROM sag_sager WHERE id=%s',(case['id'],)); parent_group=cursor.fetchone()['assigned_group_id']
cursor.execute("INSERT INTO sag_sager (titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,assigned_group_id,created_by_user_id) VALUES (%s,'','ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id",(c['title'],case.get('customer_id'),actor['id'],parent_group,actor['id']))
child=cursor.fetchone()['id']; children[c['child_index']]=child; item['id']=child
cursor.execute('INSERT INTO sag_relationer (kilde_sag_id,målsag_id,relationstype) VALUES (%s,%s,%s)',(case['id'],child,'undersag'))
cursor.execute('INSERT INTO sag_kontakter (sag_id,contact_id,role,is_primary) SELECT %s,contact_id,role,is_primary FROM sag_kontakter WHERE sag_id=%s AND deleted_at IS NULL',(child,case['id']))
elif kind in {'assign','self','group'}:
gid=c['group_id']; uid=c.get('user_id') if kind!='group' else None
cursor.execute('DELETE FROM sag_case_assignees a WHERE sag_id=%s AND NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id=a.user_id AND ug.group_id=%s)',(cid,gid))
if uid: cursor.execute('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',(cid,uid,actor['id']))
cursor.execute('UPDATE sag_sager SET assigned_group_id=%s,ansvarlig_bruger_id=%s WHERE id=%s',(gid,uid,cid))
comment(cursor,cid,author,f"Tildeling: {c.get('user_name') or c.get('group')}\nBegrundelse: {c['reason']}")
elif kind=='mail':
cursor.execute('INSERT INTO sag_command_mail_drafts (sag_id,actor_id,subject,body) VALUES (%s,%s,%s,%s) RETURNING id',(cid,actor['id'],case['titel'],c['description'])); item['id']=cursor.fetchone()['id']
elif kind=='timer':
cursor.execute('SELECT user_id FROM users WHERE user_id=%s FOR UPDATE',(actor['id'],))
if c['action']=='pause':
cursor.execute('UPDATE tmodule_times SET aktiv_timer=false,paused_at=CURRENT_TIMESTAMP WHERE medarbejder_id=%s AND sag_id=%s AND aktiv_timer=true AND slut_tid IS NULL RETURNING id',(actor['id'],cid))
paused=cursor.fetchone()
if not paused: fail('Ingen aktiv timer på denne sag')
item['id']=paused['id']
else:
cursor.execute('SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND aktiv_timer=true AND slut_tid IS NULL',(actor['id'],))
if cursor.fetchone(): raise HTTPException(409,detail='Du har allerede en aktiv timer. Pause den først.')
cursor.execute("INSERT INTO tmodule_times (sag_id,customer_id,description,original_hours,worked_date,user_name,status,billable,billing_method,start_tid,entry_type,kilde,entry_status,medarbejder_id,aktiv_timer,round_block_min,ikke_placeret,work_type) SELECT id,customer_id,%s,0.01,CURRENT_DATE,%s,'pending',(customer_id IS NOT NULL),'draft',CURRENT_TIMESTAMP,'manuel','sag_v5','kladde',%s,true,30,false,'support' FROM sag_sager WHERE id=%s RETURNING id",(c['description'],author,actor['id'],cid)); item['id']=cursor.fetchone()['id']
elif kind=='estimate': cursor.execute('UPDATE sag_sager SET estimated_minutes=%s WHERE id=%s',(c['minutes'],cid))
elif kind in {'deadline','defer','priority'}:
column={'deadline':'deadline','defer':'deferred_until','priority':'priority'}[kind]; value=c.get('date') if kind!='priority' else c['priority']
cursor.execute(f'UPDATE sag_sager SET {column}=%s WHERE id=%s',(value,cid))
comment(cursor,cid,author,f"{kind}: {value}"+(f"\nBegrundelse: {c['reason']}" if kind=='priority' else ''),system=True)
elif kind in {'todo','waiting'}:
title=c['title'] if kind=='todo' else f"Afventer {c['target']}: {c['description']}"
cursor.execute('INSERT INTO sag_todo_steps (sag_id,title,description,due_date,created_by_user_id,assigned_to_user_id) VALUES (%s,%s,%s,%s,%s,%s) RETURNING id',(cid,title,(f"Ansvarlig: {c['user_name']}" if c.get('user_name') else c.get('description')),c.get('date'),actor['id'],c.get('assigned_to_user_id'))); item['id']=cursor.fetchone()['id']
if kind=='waiting':
cursor.execute("UPDATE sag_sager SET status='Afventer' WHERE id=%s",(cid,)); comment(cursor,cid,author,title,system=True)
elif kind=='note': item['id']=comment(cursor,cid,author,c['description'])
elif kind=='solution':
cursor.execute('SELECT id,deleted_at FROM sag_solutions WHERE sag_id=%s FOR UPDATE',(cid,)); existing=cursor.fetchone()
if existing: raise HTTPException(409,detail='Sagen har allerede en løsning. Ret den eksisterende løsning i løsningsmodulet.')
cursor.execute("INSERT INTO sag_solutions (sag_id,title,description,solution_type,result,approval_status,visibility,is_final,created_by_user_id,updated_by_user_id) VALUES (%s,%s,%s,'Support','Løst','draft','internal',true,%s,%s) RETURNING *",(cid,'Løsning',c['description'],actor['id'],actor['id'])); solution=dict(cursor.fetchone()); item['id']=solution['id']
cursor.execute('INSERT INTO sag_solution_versions (solution_id,version_number,snapshot,changed_by_user_id,change_note) VALUES (%s,1,%s::jsonb,%s,%s)',(solution['id'],json.dumps(jsonable_encoder(solution)),actor['id'],'Løsning oprettet via kommando'))
elif kind=='relation':
cursor.execute("INSERT INTO sag_relationer (kilde_sag_id,målsag_id,relationstype) SELECT %s,%s,'Relateret til' WHERE NOT EXISTS (SELECT 1 FROM sag_relationer WHERE kilde_sag_id=%s AND målsag_id=%s AND relationstype='Relateret til' AND deleted_at IS NULL)",(cid,c['case_id'],cid,c['case_id']))
elif kind=='time':
cursor.execute('SELECT customer_id FROM sag_sager WHERE id=%s',(cid,)); customer=cursor.fetchone()['customer_id']; minutes=c['minutes']; rounded=((minutes+29)//30)*30
cursor.execute("""INSERT INTO tmodule_times (sag_id,customer_id,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,prepaid_card_id,fixed_price_agreement_id)
VALUES (%s,%s,%s,%s,CURRENT_DATE,%s,'pending',%s,%s,%s,%s,'manuel','sag_v5',%s,%s,false,30,false,'other',%s,%s) RETURNING id""",(cid,customer,f"{c.get('category') or 'Arbejde'}: {c['description']}",minutes/60,author,customer is not None,c['billing']['method'],minutes,rounded,c['billing']['status'],actor['id'],c['billing']['card_id'],c['billing']['agreement_id']))
item['id']=cursor.fetchone()['id']
result.append(item)
return result
async def run(sag_id,payload,actor,execute=False):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(sag_id,)); case=cursor.fetchone()
if not case: raise HTTPException(404,detail='Sagen findes ikke')
identity={'comment':payload.get('comment'),'commands':payload.get('commands'),'internal':payload.get('internal',True)}
# Keep old replay hashes valid while including new capture semantics.
if 'billing_mode' in payload: identity['billing_mode']=payload['billing_mode']
digest=hashlib.sha256(json.dumps(identity,sort_keys=True,ensure_ascii=False).encode()).hexdigest()
rid=None
if execute:
try: rid=str(UUID(str(payload.get('request_id'))))
except (ValueError,TypeError): fail('request_id skal være UUID')
cursor.execute('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))',(rid,))
cursor.execute('SELECT * FROM sag_command_batches WHERE request_id=%s',(rid,)); previous=cursor.fetchone()
if previous:
if previous['sag_id']!=sag_id or previous['actor_id']!=actor['id'] or previous['payload_hash']!=digest: raise HTTPException(409,detail='Denne request_id er allerede brugt til andre handlinger')
conn.rollback()
return previous['result']
commands=normalize(cursor,case,payload,actor)
if not execute:
conn.rollback()
return {'commands':commands,'errors':[]}
result={'request_id':rid,'results':apply(cursor,case,commands,payload,actor),'errors':[]}
cursor.execute('INSERT INTO sag_command_batches (request_id,sag_id,actor_id,payload_hash,result) VALUES (%s,%s,%s,%s,%s::jsonb)',(rid,sag_id,actor['id'],digest,json.dumps(jsonable_encoder(result))))
conn.commit(); return result
except HTTPException:
conn.rollback(); raise
except Exception:
conn.rollback()
logger.exception('Case command batch failed for case %s',sag_id)
raise HTTPException(500,detail='Handlingerne blev ikke gemt. Prøv igen; ingen delvise ændringer er gemt.')
finally:
conn.rollback()
release_db_connection(conn)
@router.post('/sag/{sag_id}/command-preview', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
async def preview_commands(sag_id:int,payload:dict,current_user:dict=Depends(get_current_user)):
return await run(sag_id,payload,current_user)
@router.post('/sag/{sag_id}/command-execute', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
async def execute_commands(sag_id:int,payload:dict,current_user:dict=Depends(get_current_user)):
return await run(sag_id,payload,current_user,True)
@router.get('/sag/{sag_id}/command-mail-drafts', dependencies=[Depends(require_any_permission('cases.view','tickets.view'))])
async def command_mail_drafts(sag_id:int,current_user:dict=Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('SELECT * FROM sag_command_mail_drafts WHERE sag_id=%s AND actor_id=%s ORDER BY id DESC',(sag_id,current_user['id']))
return list(cursor.fetchall())
finally:
conn.rollback()
release_db_connection(conn)
@router.get('/sag/{sag_id}/command-options', dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
async def command_options(sag_id:int,current_user:dict=Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,))
if not cursor.fetchone(): raise HTTPException(404,detail='Sagen findes ikke')
cursor.execute('SELECT user_id,username,full_name FROM users WHERE is_active=true ORDER BY full_name NULLS LAST,username')
users=list(cursor.fetchall())
cursor.execute('SELECT id,name FROM groups ORDER BY name')
groups=list(cursor.fetchall())
cursor.execute('SELECT id,titel FROM sag_sager WHERE deleted_at IS NULL AND id<>%s ORDER BY id DESC LIMIT 100',(sag_id,))
return {'users':users,'groups':groups,'cases':list(cursor.fetchall())}
finally:
conn.rollback()
release_db_connection(conn)

View File

@ -0,0 +1,112 @@
"""Read-only, case-scoped file browsing including linked mail attachments."""
from io import BytesIO
from pathlib import Path
from html import escape
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Response
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query
router=APIRouter(dependencies=[Depends(get_current_user)])
MAX_BYTES=25*1024*1024
def catalog(sag_id):
if not execute_query('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)):raise HTTPException(404,detail='Sagen findes ikke')
files=execute_query("SELECT *, 'case' AS source FROM sag_files WHERE sag_id=%s",(sag_id,)) or []
mails=execute_query("SELECT a.id,a.email_id,a.filename,a.content_type,a.size_bytes,a.file_path, 'mail' AS source,e.subject AS mail_subject FROM email_attachments a JOIN email_messages e ON e.id=a.email_id JOIN sag_emails se ON se.email_id=e.id WHERE se.sag_id=%s AND e.deleted_at IS NULL",(sag_id,)) or []
return files+mails
def read_file(sag_id,source,file_id):
row=next((r for r in catalog(sag_id) if r['source']==source and r['id']==file_id),None)
if not row:raise HTTPException(404,detail='Filen er ikke knyttet til sagen')
if source=='case':
from .router import _resolve_attachment_path
path=_resolve_attachment_path(row['stored_name'])
else:path=Path(row['file_path']) if row.get('file_path') else None
if path and path.is_file():
if path.stat().st_size>MAX_BYTES:raise HTTPException(413,detail='Filen er for stor til forhåndsvisning (25 MB)')
content=path.read_bytes()
else:
if source=='mail' and 'content_data' not in row:
saved=execute_query('SELECT content_data FROM email_attachments WHERE id=%s',(file_id,))
row['content_data']=saved[0].get('content_data') if saved else None
content=bytes(row.get('content_data') or b'')
if not content:raise HTTPException(404,detail='Filens indhold mangler')
if len(content)>MAX_BYTES:raise HTTPException(413,detail='Filen er for stor til forhåndsvisning (25 MB)')
return row,content
def inspect_file(name,content):
kind=Path(name).suffix.lower()
if kind=='.txt':return {'pages':1,'text':content.decode('utf-8',errors='replace')}
if kind=='.pdf':
import pdfplumber
with pdfplumber.open(BytesIO(content)) as pdf:
if len(pdf.pages)>300:raise HTTPException(413,detail='PDF har over 300 sider')
return {'pages':len(pdf.pages),'text':'\n'.join(p.extract_text() or '' for p in pdf.pages)}
return {'pages':1,'text':''}
@router.get('/sag/{sag_id}/file-browser')
def list_files(sag_id:int):
return [{'id':r['id'],'source':r['source'],'filename':r['filename'],'type':Path(r['filename']).suffix.lower().lstrip('.') or 'andet','size':r.get('size_bytes') or 0,'email_id':r.get('email_id'),'mail_subject':r.get('mail_subject')} for r in catalog(sag_id)]
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/text')
def text(sag_id:int,source:str,file_id:int):
row,content=read_file(sag_id,source,file_id)
try:return inspect_file(row['filename'],content)
except HTTPException:raise
except Exception:raise HTTPException(422,detail='Tekst kunne ikke læses; filen kan være beskyttet eller beskadiget')
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/download')
def download(sag_id:int,source:str,file_id:int):
row,content=read_file(sag_id,source,file_id)
return Response(content,media_type='application/octet-stream',headers={'Content-Disposition':"attachment; filename*=UTF-8''"+quote(row['filename'],safe=''),'X-Content-Type-Options':'nosniff'})
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/page/{page}')
def page_image(sag_id:int,source:str,file_id:int,page:int,thumbnail:bool=False):
row,content=read_file(sag_id,source,file_id)
if Path(row['filename']).suffix.lower()!='.pdf':raise HTTPException(400,detail='Sidevisning gælder PDF')
import pypdfium2 as pdfium
try:
with pdfium.PdfDocument(content) as pdf:
if page<1 or page>len(pdf):raise HTTPException(404,detail='Siden findes ikke')
p=pdf[page-1]
try:
scale=min(1.4,(220 if thumbnail else 1600)/max(p.get_size()))
bitmap=p.render(scale=scale)
try:
out=BytesIO();bitmap.to_pil().save(out,format='PNG');return Response(out.getvalue(),media_type='image/png')
finally:bitmap.close()
finally:p.close()
except HTTPException:raise
except Exception:raise HTTPException(422,detail='PDF kunne ikke vises')
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/image')
def image(sag_id:int,source:str,file_id:int,thumbnail:bool=False):
row,content=read_file(sag_id,source,file_id)
from PIL import Image, ImageOps, UnidentifiedImageError
try:
with Image.open(BytesIO(content)) as original:
if original.width*original.height>40_000_000:
raise HTTPException(413,detail='Billedet er for stort til visning')
rendered=ImageOps.exif_transpose(original)
rendered.thumbnail((220,160) if thumbnail else (2000,2000))
if rendered.mode not in ('RGB','RGBA'):rendered=rendered.convert('RGBA')
out=BytesIO();rendered.save(out,format='PNG')
return Response(out.getvalue(),media_type='image/png',headers={'X-Content-Type-Options':'nosniff'})
except HTTPException:raise
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
raise HTTPException(422,detail='Billedet kunne ikke vises')
@router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/thumbnail')
def thumbnail(sag_id:int,source:str,file_id:int):
row=next((r for r in catalog(sag_id) if r['source']==source and r['id']==file_id),None)
if not row:raise HTTPException(404,detail='Filen er ikke knyttet til sagen')
kind=Path(row['filename']).suffix.lower().lstrip('.')
try:
if kind=='pdf':return page_image(sag_id,source,file_id,1,thumbnail=True)
if kind in {'jpg','jpeg','png','gif','webp','bmp','tif','tiff','ico'}:return image(sag_id,source,file_id,thumbnail=True)
except HTTPException:pass # Keep a visible file-type thumbnail for damaged files.
label=escape((kind or 'FIL')[:10].upper())
svg=f'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="150" viewBox="0 0 120 150"><rect x="14" y="6" width="92" height="138" rx="9" fill="#edf3f6" stroke="#879cab"/><path d="M32 40h56M32 52h56M32 64h38" stroke="#a7bac7" stroke-width="4"/><rect x="22" y="88" width="76" height="34" rx="6" fill="#244e48"/><text x="60" y="109" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">{label}</text></svg>'
return Response(svg,media_type='image/svg+xml',headers={'X-Content-Type-Options':'nosniff','Content-Security-Policy':"default-src 'none'; style-src 'none'; sandbox"})

View File

@ -0,0 +1,115 @@
"""Case-scoped selection of the existing mail templates. No sending here."""
import json
import re
from uuid import UUID
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Response
from psycopg2.extras import RealDictCursor
from app.core.auth_dependencies import require_any_permission
from app.core.database import get_db_connection, release_db_connection
router=APIRouter(dependencies=[Depends(require_any_permission('cases.edit','tickets.edit'))])
MAX_FILE=20*1024*1024
MAX_TOTAL=25*1024*1024
def case_context(cursor,sag_id):
cursor.execute('SELECT s.id,s.titel,s.customer_id,c.name AS customer_name FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id WHERE s.id=%s AND s.deleted_at IS NULL',(sag_id,))
case=cursor.fetchone()
if not case:raise HTTPException(404,detail='Sagen findes ikke')
return case
def available(template,case):
return template.get('customer_id') is None or template['customer_id']==case.get('customer_id')
def rank_templates(rows,tags):
case_tags={str(tag).strip().casefold() for tag in tags}
for row in rows:
row['matched_tags']=[tag for tag in row.get('match_tags') or [] if str(tag).strip().casefold() in case_tags]
row['recommended']=bool(row['matched_tags'])
return sorted(rows,key=lambda r:(-len(r['matched_tags']),r.get('customer_id') is None,str(r['name']).casefold()))
def render_text(text,case,contact):
values={'sag_id':str(case['id']),'sag_titel':case['titel'],'kunde_navn':case.get('customer_name') or '', 'kontakt_navn':contact.get('name') or ''}
values.update(customer_name=values['kunde_navn'],name=values['kontakt_navn'])
return re.sub(r'\{\{?\s*(sag_id|sag_titel|kunde_navn|kontakt_navn|customer_name|name)\s*\}?\}',lambda m:values[m[1]],text)
@router.get('/sag/{sag_id}/mail-templates')
async def list_templates(sag_id:int):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
cur.execute("SELECT t.name FROM tags t JOIN entity_tags et ON et.tag_id=t.id WHERE et.entity_type='case' AND et.entity_id=%s",(sag_id,));tags=[r['name'] for r in cur.fetchall()]
cur.execute('SELECT t.*, (SELECT COUNT(*) FROM email_template_attachments a WHERE a.template_id=t.id) AS attachment_count FROM email_templates t WHERE t.customer_id IS NULL OR t.customer_id=%s',(case['customer_id'],))
return {'customer_id':case['customer_id'],'customer_name':case['customer_name'],'tags':tags,'templates':rank_templates([dict(r) for r in cur.fetchall()],tags)}
finally:conn.rollback();release_db_connection(conn)
@router.get('/sag/{sag_id}/mail-templates/{template_id}')
async def preview_template(sag_id:int,template_id:int):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
cur.execute('SELECT * FROM email_templates WHERE id=%s',(template_id,));template=cur.fetchone()
if not template or not available(template,case):raise HTTPException(404,detail='Skabelonen er ikke tilgængelig for denne kunde')
cur.execute("SELECT CONCAT_WS(' ',c.first_name,c.last_name) AS name,c.email FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id WHERE sk.sag_id=%s AND sk.deleted_at IS NULL ORDER BY sk.is_primary DESC,sk.id LIMIT 1",(sag_id,));contact=cur.fetchone() or {}
cur.execute('SELECT id,filename,content_type,size_bytes FROM email_template_attachments WHERE template_id=%s ORDER BY id',(template_id,))
return {'id':template_id,'name':template['name'],'subject':render_text(template['subject'],case,contact),'body':render_text(template['body'],case,contact),'to':contact.get('email') or '', 'attachments':list(cur.fetchall())}
finally:conn.rollback();release_db_connection(conn)
@router.post('/sag/{sag_id}/mail-templates')
async def create_template(sag_id:int,data:str=Form(...),files:list[UploadFile]=File(default=[])):
try:payload=json.loads(data);request_id=str(UUID(payload['request_id']))
except (ValueError,KeyError,TypeError):raise HTTPException(400,detail='Ugyldige skabelonoplysninger')
if not isinstance(payload,dict):raise HTTPException(400,detail='Ugyldige skabelonoplysninger')
for key,limit in [('name',255),('subject',255),('body',50000)]:
value=payload.get(key)
if not isinstance(value,str) or not value.strip() or len(value)>limit:raise HTTPException(400,detail='Udfyld navn, emne og besked inden for feltets længde')
scope=payload.get('scope','customer')
if scope not in {'global','customer'}:raise HTTPException(400,detail='Vælg global eller kunde')
tags=payload.get('match_tags',[])
if not isinstance(tags,list) or len(tags)>30 or any(not isinstance(t,str) or not t.strip() or len(t)>100 for t in tags):raise HTTPException(400,detail='Ugyldige tags')
assets=[];total=0
if len(files)>10:raise HTTPException(400,detail='Vælg højst 10 filer')
for file in files:
content=await file.read(MAX_FILE+1);total+=len(content)
if len(content)>MAX_FILE or total>MAX_TOTAL:raise HTTPException(413,detail='Hver fil må fylde 20 MB og filerne samlet højst 25 MB')
name=(file.filename or 'vedhaeftning').replace('\\','/').split('/')[-1].replace('\r','').replace('\n','')[:255]
assets.append((name,file.content_type or 'application/octet-stream',content,len(content)))
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
customer=case['customer_id'] if scope=='customer' else None
if scope=='customer' and not customer:raise HTTPException(400,detail='Sagen skal have en kunde for at gemme en kundeskabelon')
cur.execute('SELECT pg_advisory_xact_lock(hashtextextended(%s,0))',(request_id,))
cur.execute('SELECT id,customer_id,name,subject,body,match_tags FROM email_templates WHERE case_request_id=%s',(request_id,));previous=cur.fetchone()
if previous:
if previous['customer_id']!=customer or any(previous[k]!=payload[k].strip() for k in ['name','subject','body']) or previous['match_tags']!=tags:raise HTTPException(409,detail='Gemningen tilhører en anden skabelon')
cur.execute('SELECT filename,content_type,content,size_bytes FROM email_template_attachments WHERE template_id=%s ORDER BY id',(previous['id'],))
saved=[(r['filename'],r['content_type'],bytes(r['content']),r['size_bytes']) for r in cur.fetchall()]
if saved!=assets:raise HTTPException(409,detail='Gemningen tilhører andre vedhæftninger')
return {'id':previous['id']}
cur.execute("INSERT INTO email_templates (name,slug,subject,body,category,customer_id,match_tags,case_request_id,is_system) VALUES (%s,%s,%s,%s,'case',%s,%s::jsonb,%s,false) RETURNING id",(payload['name'].strip(),'case-'+request_id,payload['subject'].strip(),payload['body'].strip(),customer,json.dumps(tags),request_id));tid=cur.fetchone()['id']
for asset in assets:cur.execute('INSERT INTO email_template_attachments (template_id,filename,content_type,content,size_bytes) VALUES (%s,%s,%s,%s,%s)',(tid,*asset))
conn.commit();return {'id':tid}
except Exception:conn.rollback();raise
finally:conn.rollback();release_db_connection(conn)
def send_attachments(sag_id,ids):
if not ids:return []
if len(ids)>10:raise HTTPException(400,detail='Vælg højst 10 skabelonfiler')
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
case=case_context(cur,sag_id)
cur.execute('SELECT a.*,t.customer_id FROM email_template_attachments a JOIN email_templates t ON t.id=a.template_id WHERE a.id=ANY(%s)',(list(set(ids)),));rows=cur.fetchall()
if len(rows)!=len(set(ids)) or any(not available(row,case) for row in rows):raise HTTPException(400,detail='En vedhæftning er ikke tilgængelig for denne kunde')
if sum(r['size_bytes'] for r in rows)>MAX_TOTAL:raise HTTPException(413,detail='Skabelonfilerne må samlet fylde højst 25 MB')
return [{'filename':r['filename'],'content_type':r['content_type'],'content':bytes(r['content']),'size':r['size_bytes']} for r in rows]
finally:conn.rollback();release_db_connection(conn)
@router.get('/sag/{sag_id}/mail-template-files/{attachment_id}')
async def download_attachment(sag_id:int,attachment_id:int):
asset=send_attachments(sag_id,[attachment_id])[0]
return Response(asset['content'],media_type='application/octet-stream',headers={'Content-Disposition':f"attachment; filename*=UTF-8''{quote(asset['filename'],safe='')}",'X-Content-Type-Options':'nosniff'})

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
@ -157,6 +157,13 @@ def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Opti
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
def _validate_deferred_deadline(deferred_until, deadline):
deferred = _normalize_optional_timestamp(deferred_until, "deferred_until")
limit = _normalize_optional_timestamp(deadline, "deadline")
if deferred and limit and deferred > limit:
raise HTTPException(status_code=400, detail="Udsat må ikke ligge efter deadline")
def _coerce_optional_int(value: Optional[object], field_name: str) -> Optional[int]:
if value is None or value == "":
return None
@ -371,6 +378,7 @@ class SagSendEmailRequest(BaseModel):
bcc: List[str] = Field(default_factory=list)
body_html: Optional[str] = None
attachment_file_ids: List[int] = Field(default_factory=list)
template_attachment_ids: List[int] = Field(default_factory=list)
thread_email_id: Optional[int] = None
thread_key: Optional[str] = None
@ -407,6 +415,13 @@ class CaseAssignmentsUpdate(BaseModel):
primary_user_id: Optional[int] = None
class CaseAssignmentWithReason(BaseModel):
group_id: int = Field(gt=0)
primary_user_id: Optional[int] = Field(default=None, gt=0)
reason: str = Field(min_length=1, max_length=2000)
content: str = Field(min_length=1, max_length=20000)
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
@ -1000,6 +1015,7 @@ async def create_sag(request: Request, data: dict):
status = _normalize_case_status(data.get("status"))
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
_validate_deferred_deadline(deferred_until, deadline)
current_user_id = _get_user_id_from_request(request)
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
if case_type == "intern_ticket":
@ -1666,18 +1682,113 @@ 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)])
async def assign_case_with_reason(sag_id: int, payload: CaseAssignmentWithReason, request: Request,
current_user: dict = Depends(get_current_user)):
"""V5: save assignment and its internal explanation in one transaction."""
reason, content = payload.reason.strip(), payload.content.strip()
if not reason or not content:
raise HTTPException(status_code=400, detail="Kommentar og begrundelse skal udfyldes")
if not table_has_column("sag_kommentarer", "er_intern"):
raise HTTPException(status_code=409, detail="Interne kommentarer skal være understøttet før tildeling med begrundelse")
actor_id = int(current_user["id"])
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT id, ansvarlig_bruger_id, assigned_group_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE", (sag_id,))
previous = cursor.fetchone()
if not previous:
raise HTTPException(status_code=404, detail="Case not found")
cursor.execute("SELECT name FROM groups WHERE id=%s", (payload.group_id,))
group = cursor.fetchone()
if not group:
raise HTTPException(status_code=400, detail="Gruppen findes ikke")
target_name = "Ingen primær ansvarlig"
if payload.primary_user_id is not None:
cursor.execute("""SELECT COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS name
FROM users u JOIN user_groups ug ON ug.user_id=u.user_id
WHERE u.user_id=%s AND ug.group_id=%s AND u.is_active=true""",
(payload.primary_user_id, payload.group_id))
target = cursor.fetchone()
if not target:
raise HTTPException(status_code=400, detail="Medarbejderen skal være aktiv og medlem af gruppen")
target_name = target["name"]
cursor.execute("SELECT COALESCE(NULLIF(TRIM(full_name), ''), username) AS name FROM users WHERE user_id=%s", (actor_id,))
actor = cursor.fetchone()
if not actor:
raise HTTPException(status_code=403, detail="Brugeren findes ikke")
cursor.execute("SELECT COALESCE(NULLIF(TRIM(full_name), ''), username) AS name FROM users WHERE user_id=%s", (previous["ansvarlig_bruger_id"],))
old_user = cursor.fetchone()
cursor.execute("SELECT name FROM groups WHERE id=%s", (previous["assigned_group_id"],))
old_group = cursor.fetchone()
note = (f"{content}\n\nTildeling: {old_user['name'] if old_user else 'Ingen ansvarlig'} → {target_name}"
f"\nGruppe: {old_group['name'] if old_group else 'Ingen gruppe'} → {group['name']}"
f"\nBegrundelse: {reason}")
# V4 displays stored comment HTML. V5 assignment notes are plain
# text, including user-entered reasons and employee/group names.
note = html.escape(note)
# Keep other employees in the chosen group; remove incompatible rows
# on a group change rather than discarding all existing colleagues.
cursor.execute("""DELETE FROM sag_case_assignees a WHERE a.sag_id=%s
AND NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id=a.user_id AND ug.group_id=%s)""",
(sag_id, payload.group_id))
if payload.primary_user_id is not None:
cursor.execute("""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, payload.primary_user_id, actor_id))
cursor.execute("UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
(payload.group_id, payload.primary_user_id, sag_id))
cursor.execute("""INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked, er_intern)
VALUES (%s, %s, %s, false, true) RETURNING *""", (sag_id, actor["name"], note))
comment = dict(cursor.fetchone())
conn.commit()
return {"comment": comment, "group_id": payload.group_id, "primary_user_id": payload.primary_user_id}
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.exception("Could not save assignment with reason for case %s", sag_id)
raise HTTPException(status_code=500, detail="Tildeling og kommentar blev ikke gemt")
finally:
release_db_connection(conn)
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
async def list_todo_steps(sag_id: int):
try:
@ -1685,10 +1796,12 @@ async def list_todo_steps(sag_id: int):
SELECT
t.*,
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name,
COALESCE(u_assigned.full_name, u_assigned.username) AS assigned_to_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
LEFT JOIN users u_assigned ON u_assigned.user_id = NULLIF(to_jsonb(t)->>'assigned_to_user_id', '')::integer
WHERE t.sag_id = %s AND t.deleted_at IS NULL
ORDER BY t.is_done ASC, t.is_next DESC, t.due_date NULLS LAST, t.created_at DESC
"""
@ -1945,6 +2058,11 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
if "deadline" in updates or "deferred_until" in updates:
_validate_deferred_deadline(
updates.get("deferred_until", previous_row.get("deferred_until")),
updates.get("deadline", previous_row.get("deadline")),
)
if "deferred_until_status" in updates:
updates["deferred_until_status"] = _normalize_deferred_statuses(updates.get("deferred_until_status"))
if "priority" in updates:
@ -3013,6 +3131,48 @@ async def list_case_hardware(sag_id: int):
logger.error("❌ Error listing case hardware: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case hardware")
@router.get("/sag/{sag_id}/locations/{location_id}/hardware")
async def list_case_location_hardware(sag_id: int, location_id: int):
"""List hardware physically assigned to a case location and its descendants."""
try:
case = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not case:
raise HTTPException(status_code=404, detail="Case not found")
linked_location = execute_query(
"SELECT id FROM sag_lokationer WHERE sag_id = %s AND location_id = %s AND deleted_at IS NULL",
(sag_id, location_id),
)
if not linked_location:
raise HTTPException(status_code=404, detail="Location is not linked to this case")
return execute_query(
"""
WITH RECURSIVE location_tree AS (
SELECT id, name, parent_location_id, ARRAY[id] AS path
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL
UNION ALL
SELECT child.id, child.name, child.parent_location_id, parent.path || child.id
FROM locations_locations child
JOIN location_tree parent ON child.parent_location_id = parent.id
WHERE child.deleted_at IS NULL AND NOT child.id = ANY(parent.path)
)
SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number,
h.customer_asset_id, h.internal_asset_id, h.status,
tree.id AS location_id, tree.name AS location_name
FROM location_tree tree
JOIN hardware_assets h ON h.current_location_id = tree.id AND h.deleted_at IS NULL
ORDER BY tree.name, h.asset_type, h.brand, h.model, h.serial_number
""",
(location_id,),
) or []
except HTTPException:
raise
except Exception as exc:
logger.error("❌ Error listing hardware for case %s location %s: %s", sag_id, location_id, exc)
raise HTTPException(status_code=500, detail="Failed to list location hardware")
@router.post("/sag/{sag_id}/hardware")
async def add_case_hardware(sag_id: int, data: dict):
"""Add hardware to case."""
@ -6048,6 +6208,10 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
"file_path": str(path),
})
if payload.template_attachment_ids:
from .mail_templates import send_attachments
smtp_attachments.extend(send_attachments(sag_id, payload.template_attachment_ids))
in_reply_to_header = None
references_header = None
selected_thread_key = None
@ -6521,3 +6685,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
# ============================================================================
from . import solutions
router.include_router(solutions.router)
# Opt-in atomic command composer; legacy routes retain their contracts.
from .commands import router as command_router
router.include_router(command_router)
from .mail_templates import router as case_mail_template_router
router.include_router(case_mail_template_router)
from .file_browser import router as case_file_browser_router
router.include_router(case_file_browser_router)

View File

@ -418,7 +418,7 @@ async def list_solutions(
tuple(params),
) or {"total": 0}
items = execute_query(
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, sg.customer_id, c.name AS customer_name,
COALESCE(creator.full_name,creator.username) AS created_by,
COALESCE(updater.full_name,updater.username) AS updated_by,
ka.id AS article_id, ka.status AS article_status

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

@ -125,7 +125,7 @@ templates.env.filters["safe_case_html"] = sanitize_safe_html
def _fetch_assignment_users():
return execute_query(
"""
SELECT user_id, COALESCE(full_name, username) AS display_name
SELECT user_id, username, full_name, COALESCE(full_name, username) AS display_name
FROM users
ORDER BY display_name
""",
@ -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
@ -531,8 +537,8 @@ async def sag_varekob_salg(request: Request):
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def sag_detaljer(request: Request, sag_id: int):
"""Redirect the unversioned case URL to the active V4 page."""
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
"""Redirect the unversioned case URL to the active V5 page."""
return RedirectResponse(url=f"/sag/{sag_id}/v5", status_code=307)
"""Display case details."""
try:
@ -755,6 +761,7 @@ async def sag_detaljer(request: Request, sag_id: int):
WHERE customer_id = %s
AND status = 'active'
AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
@ -780,6 +787,7 @@ async def sag_detaljer(request: Request, sag_id: int):
)
WHERE a.customer_id = %s
AND a.status = 'active'
AND (a.start_date IS NULL OR a.start_date <= CURRENT_DATE)
AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
ORDER BY a.created_at DESC
"""
@ -959,6 +967,7 @@ async def sag_detaljer(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v5", response_class=HTMLResponse)
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
async def sag_detaljer_v4(request: Request, sag_id: int):
"""Display the active V4 case page."""
@ -1136,6 +1145,7 @@ async def sag_detaljer_v4(request: Request, sag_id: int):
WHERE customer_id = %s
AND status = 'active'
AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
@ -1161,6 +1171,7 @@ async def sag_detaljer_v4(request: Request, sag_id: int):
)
WHERE a.customer_id = %s
AND a.status = 'active'
AND (a.start_date IS NULL OR a.start_date <= CURRENT_DATE)
AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
ORDER BY a.created_at DESC
"""
@ -1293,13 +1304,21 @@ async def sag_detaljer_v4(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
# V4 uses the canonical workflow statuses only. Legacy values are
# still rendered as the case's current value, but must not become a
# selectable transition in its status or trigger menus.
status_options = _fetch_case_status_options()
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
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,
@ -1337,8 +1356,8 @@ async def sag_detaljer_v4(request: Request, sag_id: int):
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
async def sag_detaljer_v3_legacy(sag_id: int):
"""Keep old links working, while making V4 the canonical case URL."""
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
"""Keep old links working, while making V5 the canonical case URL."""
return RedirectResponse(url=f"/sag/{sag_id}/v5", status_code=307)
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)

View File

@ -3,6 +3,8 @@
{% block title %}Ny Sag - BMC Hub{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/static/css/case-command-composer.css?v=20261006-4">
<link rel="stylesheet" href="/static/css/case-create-v5.css?v=20261014-1">
<style>
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
@ -97,8 +99,8 @@
.selected-item {
display: inline-flex;
align-items: center;
background-color: #e3f2fd;
color: #0f4c75;
background-color: var(--theme-primary-bg, #e3f2fd);
color: var(--theme-primary-text, #0f4c75);
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
@ -111,7 +113,7 @@
.selected-item button {
background: none;
border: none;
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
margin-left: 8px;
padding: 0;
line-height: 1;
@ -283,8 +285,11 @@
{% endblock %}
{% block content %}
<script src="/static/js/quick-time.js?v=20261007-4"></script>
<script src="/static/js/case-types.js?v=20261004-1"></script>
<div class="container py-4 cc-page">
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
<header class="cc-v5-hero"><span class="cc-v5-eyebrow">SAGSBORD</span><h1>Ny sag</h1><p>Beskriv sagen, vælg kontakt og tilføj handlinger med /.</p></header>
<div class="row justify-content-center">
<div class="col-12">
<!-- Main Card -->
@ -307,7 +312,8 @@
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
</div>
<!-- Section: Relations -->
<!-- Selected only when the chosen case type needs relations. -->
<section id="caseRelationsSection" class="d-none">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
<!-- Contact Search -->
@ -338,6 +344,7 @@
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
</section>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<div class="row g-4 mb-4">
@ -350,7 +357,7 @@
{% endfor %}
</select>
</div>
<div class="col-md-6">
<div class="col-md-6 d-none" id="caseGroupField">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
@ -380,7 +387,7 @@
<i class="bi bi-magic me-1"></i>AI renskriv
</button>
</div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..." required></textarea>
<textarea class="form-control" id="beskrivelse" rows="6" placeholder="Beskriv sagen. Skriv / på en ny linje for handlinger…" required></textarea>
<div class="d-flex justify-content-between form-text">
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
<span id="charCount">0 tegn</span>
@ -388,6 +395,15 @@
</div>
</div>
<section id="caseTagsSection" class="mb-4">
<label class="form-label">Tags</label>
<div class="d-flex flex-wrap align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="caseTagsButton"><i class="bi bi-tags me-1"></i>Vælg tags</button>
<div id="cc-tag-chips" class="d-flex flex-wrap gap-1"></div>
</div>
<div id="cc-tag-suggestions"></div>
</section>
<hr class="my-4 opacity-25">
<!-- Fields that belong to the selected case type are rendered here. -->
@ -396,7 +412,7 @@
<div id="typeModuleAction" class="d-flex flex-wrap gap-2 mt-4 mb-4 d-none" aria-label="Tilføj moduler"></div>
<!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<section id="hardwareSection" class="d-none">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4">
<div class="col-12">
@ -1502,9 +1518,8 @@
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
const supportedTypes = ['ticket', 'postit', 'salg', 'bogholderi', 'admin', 'projekt', 'intern_ticket', 'bug_report'];
const finalTypes = supportedTypes;
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
const finalTypes = BmcCaseTypes.normalize(configuredTypes);
BmcCaseTypes.populate(select, finalTypes, {labels: caseTypeLabels});
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (err) {
console.error('Failed to load case types', err);
@ -1538,14 +1553,41 @@
// Both the selected type and current user are now known. Reapply the
// type defaults so Intern ticket cannot display a stale assignee.
updateCaseTypeSections(false);
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(false));
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(true));
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
// --- Form Submission ---
let caseCreationSubmitting = false;
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (caseCreationSubmitting) return;
caseCreationSubmitting = true;
try {
const commands = window.caseCreateCommands;
if (commands) await commands.ready;
if (commands?.hasPending()) {
const btn = document.getElementById('submitBtn');
const caseId = commands.pendingCaseId();
btn.disabled = true;
try {
await commands.retry();
window.caseCreateUI?.created({id: caseId});
window.location.href = `/sag/${caseId}/v5`;
} catch (err) {
document.getElementById('error').classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
} finally { btn.disabled = false; }
return;
}
let commandDraft = null;
try { commandDraft = commands?.prepare(); }
catch (err) {
document.getElementById('error').classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
return;
}
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
// UI Reset
@ -1571,6 +1613,7 @@
// Loading State
btn.disabled = true;
commands?.setCreating(true);
const originalBtnText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
@ -1607,13 +1650,14 @@
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
btn.disabled = false;
commands?.setCreating(false);
btn.innerHTML = originalBtnText;
return;
}
const data = {
titel: titel,
beskrivelse: document.getElementById('beskrivelse').value || '',
beskrivelse: commandDraft?.comment ?? (document.getElementById('beskrivelse').value || ''),
type: document.getElementById('type').value,
status: status,
customer_id: selectedCustomer ? selectedCustomer.id : null,
@ -1650,11 +1694,12 @@
if (response.ok) {
const result = await response.json();
await commands?.created(result);
window.caseCreateUI?.created(result);
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet";
window.location.href = `/sag/${result.id}/v4`;
window.location.href = `/sag/${result.id}/v5`;
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
@ -1670,9 +1715,15 @@
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
btn.disabled = false;
btn.innerHTML = originalBtnText;
commands?.setCreating(false);
btn.innerHTML = commands?.hasPending() ? 'Prøv kommandoerne igen' : originalBtnText;
}
} finally { caseCreationSubmitting = false; }
});
</script>
<script src="/static/js/case-create.js?v=13"></script>
<script src="/static/js/case-create.js?v=14"></script>
<script id="caseCreateCommandData" type="application/json">{{ {'users': assignment_users or [], 'groups': assignment_groups or []}|tojson }}</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-create-commands.js?v=20261014-1"></script>
{% endblock %}

View File

@ -351,8 +351,8 @@
}
.tag-closed {
background-color: #e0e0e0;
color: #666;
background-color: var(--theme-surface-soft, #e0e0e0);
color: var(--theme-muted, #666);
text-decoration: line-through;
}
@ -370,13 +370,13 @@
}
.tag-state-open {
background: #d4edda;
color: #155724;
background: var(--theme-success-bg, #d4edda);
color: var(--theme-success-text, #155724);
}
.tag-state-closed {
background: #f8d7da;
color: #721c24;
background: var(--theme-danger-bg, #f8d7da);
color: var(--theme-danger-text, #721c24);
}
[data-bs-theme="dark"] .tag-state-open {
@ -460,14 +460,14 @@
}
.case-status-chip.open {
background: #dcfce7;
color: #15803d;
background: var(--theme-success-bg, #dcfce7);
color: var(--theme-text, #15803d);
border-color: #86efac;
}
.case-status-chip.closed {
background: #f1f5f9;
color: #475569;
background: var(--theme-surface-soft, #f1f5f9);
color: var(--theme-text, #475569);
border-color: #cbd5e1;
}
@ -643,15 +643,15 @@
}
.case-date-badge.overdue {
background: #fef2f2;
background: var(--theme-danger-bg, #fef2f2);
border-color: #fca5a5;
color: #b91c1c;
color: var(--theme-danger-text, #b91c1c);
}
.case-date-badge.deferred {
background: #fffbeb;
background: var(--theme-surface-soft, #fffbeb);
border-color: #fcd34d;
color: #92400e;
color: var(--theme-warning-text, #92400e);
}
[data-bs-theme="dark"] .case-date-badge {
@ -1089,7 +1089,7 @@
.comment-category-badge.category-external {
background: rgba(15, 76, 117, 0.12);
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
border-color: rgba(15, 76, 117, 0.28);
}
@ -1900,7 +1900,7 @@
color: var(--accent);
}
.rel-tag-popover .rel-tag-suggestion.new-tag {
color: #198754;
color: var(--theme-success-text, #198754);
font-style: italic;
}
@ -1946,19 +1946,19 @@
.relation-type-pill.is-root {
background: rgba(15, 76, 117, 0.12);
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
border-color: rgba(15, 76, 117, 0.25);
}
.relation-type-pill.is-related {
background: rgba(100, 116, 139, 0.12);
color: #334155;
color: var(--theme-text, #334155);
border-color: rgba(100, 116, 139, 0.25);
}
.relation-type-pill.is-derived {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
color: var(--theme-primary-text, #1d4ed8);
border-color: rgba(59, 130, 246, 0.25);
}
@ -1970,7 +1970,7 @@
.relation-type-pill.is-block {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
color: var(--theme-danger-text, #b91c1c);
border-color: rgba(239, 68, 68, 0.3);
}
@ -2082,7 +2082,7 @@
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6c757d;
color: var(--theme-muted, #6c757d);
padding: 0 0.25rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
@ -2132,7 +2132,7 @@
}
.contact-row small {
color: #6c757d;
color: var(--theme-muted, #6c757d);
}
.contact-actions {
@ -2186,7 +2186,7 @@
}
.contact-info-subtitle {
color: #6c757d;
color: var(--theme-muted, #6c757d);
font-size: 0.82rem;
margin-top: 0.1rem;
}
@ -2226,7 +2226,7 @@
}
.contact-info-label {
color: #6c757d;
color: var(--theme-muted, #6c757d);
font-size: 0.78rem;
min-width: 90px;
}
@ -2254,7 +2254,7 @@
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6c757d;
color: var(--theme-muted, #6c757d);
padding: 0 0.25rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
@ -2339,7 +2339,7 @@
}
.tag-toggle-closed {
color: #6c757d;
color: var(--theme-muted, #6c757d);
border-color: #6c757d;
}
@ -2779,16 +2779,16 @@
}
.case-tabs-topbar.topbar-secondary .case-inline-select {
background: #ffffff;
background: var(--theme-surface, #ffffff);
border: 1px solid rgba(15, 76, 117, 0.34);
color: #0f2f45;
color: var(--theme-text, #0f2f45);
font-weight: 700;
}
.case-tabs-topbar.topbar-secondary .topbar-secondary-action {
border-color: rgba(15, 76, 117, 0.34);
background: #ffffff;
color: #0f2f45;
background: var(--theme-surface, #ffffff);
color: var(--theme-text, #0f2f45);
}
.case-tabs-topbar.topbar-secondary .topbar-secondary-action:hover,
@ -2984,7 +2984,7 @@
}
.topbar-deferred-current.is-set {
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
border-style: solid;
border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.09);
@ -3308,8 +3308,8 @@
}
#caseTabs .nav-link.active {
color: #0f4c75;
background: #ffffff;
color: var(--theme-text, #0f4c75);
background: var(--theme-surface, #ffffff);
border-color: rgba(255, 255, 255, 0.78);
border-bottom-color: #ffffff;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
@ -3321,7 +3321,7 @@
}
#caseTabs .nav-link.active i {
color: #0f4c75;
color: var(--theme-primary-text, #0f4c75);
opacity: 1;
}
@ -3692,6 +3692,7 @@
{% endblock %}
{% block content %}
<script src="/static/js/case-types.js?v=20261004-1"></script>
<div class="case-detail-page-shell" style="background: linear-gradient(to right, #0f4c75, #1e3c72) 0 0 / 100% 420px no-repeat, #ffffff; min-height: 100vh; margin-top: -1.35rem;">
<div class="modal fade" id="closeCaseWithoutTimeModal" tabindex="-1" aria-labelledby="closeCaseWithoutTimeTitle" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
@ -3792,7 +3793,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<select data-current-type="{{ case.template_key or case.type or 'ticket' }}" id="topbarTypeSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
@ -3919,7 +3920,7 @@
<!-- Widget 4: Alerts & Genveje -->
<div class="col-md-6 col-xl-3">
<div class="card h-100 shadow-sm border-0 rounded-3" style="background:#f8fafc;">
<div class="card h-100 shadow-sm border-0 rounded-3" style="background:var(--theme-surface-soft, #f8fafc);">
<div class="card-body p-2">
<div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-info-circle-fill text-secondary me-1"></i> System Info</div>
@ -5505,7 +5506,7 @@
if (payload?.detail) detail = payload.detail;
} catch (_) {
}
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:#b42318;">${escapeHtml(detail)}</div>`;
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:var(--theme-danger-text, #b42318);">${escapeHtml(detail)}</div>`;
return;
}
@ -5517,7 +5518,7 @@
popup.document.title = fallbackTitle;
}
} catch (error) {
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:#b42318;">Fejl ved hentning af printvisning: ${escapeHtml(error?.message || 'Ukendt fejl')}</div>`;
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:var(--theme-danger-text, #b42318);">Fejl ved hentning af printvisning: ${escapeHtml(error?.message || 'Ukendt fejl')}</div>`;
}
}
@ -5862,6 +5863,7 @@
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
titel: title,
type: document.getElementById('newRelatedCaseType')?.value || 'ticket',
beskrivelse: description,
customer_id: customerId,
status: 'åben'
@ -9175,14 +9177,14 @@
.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:#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:#fff;border-color:#a9cedc}
.agreement-toolbar{border:1px solid var(--sub-line);background:#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:#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:#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}}
.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:#fff;color:#145c80;box-shadow:0 3px 10px rgba(30,64,99,.1)}.invoice-paper{position:relative;min-height:440px;border-radius:12px;background:#fff;padding:1.15rem;box-shadow:0 5px 18px rgba(31,55,74,.1);color:#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:#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}
.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}}
@ -15252,6 +15254,7 @@
</div>
<div class="modal-body">
<form id="createRelatedForm">
<div class="mb-3"><label class="form-label" for="newRelatedCaseType">Sagstype</label><select id="newRelatedCaseType" class="form-select"><option value="ticket">Ticket</option></select></div>
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
@ -16553,7 +16556,7 @@
};
const nextType = (select.value || 'ticket').toLowerCase();
const meta = typeMeta[nextType] || typeMeta.ticket;
const meta = typeMeta[nextType] || {label: nextType, icon: 'bi-tag', color: '#64748b'};
saveCaseType(nextType, meta.label, meta.icon, meta.color);
}
@ -20784,6 +20787,7 @@
credentials: 'include',
body: JSON.stringify({
titel: title,
type: document.getElementById('newRelatedCaseType')?.value || 'ticket',
beskrivelse: description,
status: 'åben',
customer_id: customerId

File diff suppressed because it is too large Load Diff

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