checkpoint: before bottom bar and side panel fixes

This commit is contained in:
Christian 2026-08-17 19:27:43 +02:00
parent 533a652337
commit 88f7cec478
43 changed files with 2198 additions and 446 deletions

View File

@ -10,11 +10,11 @@
<div class="card-body p-4">
<div class="text-center mb-4">
<h2 class="fw-bold" style="color: var(--primary-color);">2FA Setup</h2>
<p class="text-muted">Opsaet tofaktor for din konto</p>
<p class="text-muted">Opsæt tofaktor for din konto</p>
</div>
<div id="statusMessage" class="alert alert-info" role="alert">
Klik "Generer 2FA" for at starte opsaetningen.
Klik "Generer 2FA" for at starte opsætningen.
</div>
<div class="d-grid gap-2 mb-3">

View File

@ -126,7 +126,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
if (data.requires_2fa_setup) {
const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?');
const goSetup = confirm('2FA er ikke opsat. Vil du opsætte 2FA nu?');
window.location.href = goSetup ? '/2fa/setup' : '/';
return;
}

View File

@ -1,6 +1,6 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Leverandør fakturaer - BMC Hub{% endblock %}
{% block title %}Leverandørfakturaer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
@ -206,7 +206,7 @@
<ul class="nav nav-tabs mb-4" id="mainTabs">
<li class="nav-item">
<a class="nav-link active" id="unhandled-tab" data-bs-toggle="tab" href="#unhandled-content" onclick="switchToUnhandledTab()">
<i class="bi bi-inbox me-2"></i>Ubehandlede Fakturaer
<i class="bi bi-inbox me-2"></i>Ubehandlede fakturaer
<span class="badge bg-warning text-dark ms-2" id="unhandledCount" style="display: none;">0</span>
</a>
</li>
@ -241,7 +241,7 @@
<div class="alert alert-info mb-4">
<i class="bi bi-inbox me-2"></i>
<strong>Ubehandlede Fakturaer:</strong> PDFer der venter på analyse og vendor-matching. Klik "Analyser alle" for at køre automatisk extraction.
<strong>Ubehandlede fakturaer:</strong> PDF-filer, der venter på analyse og leverandørmatch. Klik på "Analyser alle" for at køre automatisk udtrækning.
</div>
<!-- Batch Actions Bar -->
@ -349,7 +349,7 @@
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i>
<strong>Til Betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug checkboxes til at vælge hvilke der skal betales.
<strong>Til betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug afkrydsningsfelterne til at vælge, hvilke der skal betales.
</div>
<!-- Bulk Actions Bar for Payment -->
@ -559,7 +559,7 @@
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-success" onclick="bulkCreateInvoices()" title="Opret fakturaer">
<i class="bi bi-plus-circle me-1"></i>Opret Fakturaer
<i class="bi bi-plus-circle me-1"></i>Opret fakturaer
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="bulkReprocess()" title="Genbehandle filer">
<i class="bi bi-arrow-clockwise me-1"></i>Genbehandle

View File

@ -6,7 +6,14 @@ Only GET endpoints for now
from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
from app.core.database import (
execute_query,
execute_insert,
execute_query_single,
get_db_connection,
release_db_connection,
)
from psycopg2.extras import RealDictCursor
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
@ -17,6 +24,7 @@ from app.customers.backend.router import (
SubscriptionComment,
)
import logging
import json
logger = logging.getLogger(__name__)
router = APIRouter()
@ -51,6 +59,26 @@ class ContactUpdate(BaseModel):
is_active: Optional[bool] = None
class ContactMergeRequest(BaseModel):
source_contact_id: int = Field(..., gt=0)
CONTACT_MERGE_RELATIONS = (
("Firmaer", "contact_companies", "contact_id"),
("Sager", "sag_kontakter", "contact_id"),
("Opkald", "telefoni_opkald", "kontakt_id"),
("SMS", "sms_messages", "kontakt_id"),
("E-mails", "tticket_email_metadata", "matched_contact_id"),
("Tickets", "tticket_tickets", "contact_id"),
("Ticketrelationer", "tticket_contacts", "contact_id"),
("AnyDesk-sessioner", "anydesk_sessions", "contact_id"),
("Forsendelser", "fedex_shipments", "contact_id"),
("Hardware", "hardware_contacts", "contact_id"),
("Salgsmuligheder", "pipeline_opportunity_contacts", "contact_id"),
("Lokationer", "locations_contacts", "related_contact_id"),
)
class ContactCompanyLink(BaseModel):
customer_id: int
is_primary: bool = True
@ -290,6 +318,162 @@ async def create_contact(contact: ContactCreate):
raise HTTPException(status_code=500, detail=str(e))
def _contact_merge_counts(contact_id: int) -> list[dict]:
counts = []
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)})
conversation_row = execute_query_single(
"""
SELECT COUNT(DISTINCT conversation.id)::int AS count
FROM conversations conversation
JOIN contact_companies cc ON cc.customer_id = conversation.customer_id
WHERE cc.contact_id = %s
""",
(contact_id,),
) or {}
counts.append({
"key": "conversations_via_company",
"label": "Samtaler via firma",
"count": int(conversation_row.get("count") or 0),
"preserved_via": "company",
})
return counts
@router.get("/contacts/{contact_id}/merge-preview")
async def preview_contact_merge(contact_id: int, source_contact_id: int = Query(..., gt=0)):
if contact_id == source_contact_id:
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
target = execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
source = execute_query_single("SELECT * FROM contacts WHERE id = %s", (source_contact_id,))
if not target or not source:
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
relations = _contact_merge_counts(source_contact_id)
return {
"target": target,
"source": source,
"relations": relations,
"total_relations": sum(item["count"] for item in relations),
}
@router.post("/contacts/{contact_id}/merge")
async def merge_contact(contact_id: int, request: ContactMergeRequest):
source_id = int(request.source_contact_id)
if contact_id == source_id:
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT * FROM contacts WHERE id IN (%s, %s) FOR UPDATE", (contact_id, source_id))
rows = cursor.fetchall()
by_id = {int(row["id"]): dict(row) for row in rows}
target = by_id.get(contact_id)
source = by_id.get(source_id)
if not target or not source:
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
moved = {}
# Preserve missing master data on the target contact.
merge_fields = ("first_name", "last_name", "email", "phone", "mobile", "title", "department", "user_company")
assignments = []
values = []
for field in merge_fields:
target_value = target.get(field)
source_value = source.get(field)
if (target_value is None or str(target_value).strip() == "") and source_value not in (None, ""):
assignments.append(f"{field} = %s")
values.append(source_value)
if assignments:
values.append(contact_id)
cursor.execute(f"UPDATE contacts SET {', '.join(assignments)}, updated_at = NOW() WHERE id = %s", tuple(values))
cursor.execute(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
SELECT %s, customer_id, is_primary, role, notes
FROM contact_companies WHERE contact_id = %s
ON CONFLICT (contact_id, customer_id) DO UPDATE SET
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
role = COALESCE(contact_companies.role, EXCLUDED.role),
notes = COALESCE(contact_companies.notes, EXCLUDED.notes)
""",
(contact_id, source_id),
)
cursor.execute("SELECT COUNT(*)::int AS count FROM contact_companies WHERE contact_id = %s", (source_id,))
moved["contact_companies"] = int(cursor.fetchone()["count"] or 0)
cursor.execute("DELETE FROM contact_companies WHERE contact_id = %s", (source_id,))
unique_relations = (
("hardware_contacts", "contact_id", "hardware_id", "TRUE", "TRUE"),
("pipeline_opportunity_contacts", "contact_id", "opportunity_id", "TRUE", "TRUE"),
("tticket_contacts", "contact_id", "ticket_id", "TRUE", "TRUE"),
("locations_contacts", "related_contact_id", "location_id", "src.deleted_at IS NULL", "dst.deleted_at IS NULL"),
)
for table, column, owner_column, source_clause, target_clause in unique_relations:
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
moved[table] = int(cursor.fetchone()["count"] or 0)
cursor.execute(
f"DELETE FROM {table} src WHERE src.{column} = %s AND {source_clause} "
f"AND EXISTS (SELECT 1 FROM {table} dst WHERE dst.{column} = %s "
f"AND dst.{owner_column} = src.{owner_column} AND {target_clause})",
(source_id, contact_id),
)
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
# Avoid duplicate active case-contact rows, while retaining roles and history.
cursor.execute("SELECT COUNT(*)::int AS count FROM sag_kontakter WHERE contact_id = %s", (source_id,))
moved["sag_kontakter"] = int(cursor.fetchone()["count"] or 0)
cursor.execute(
"""
DELETE FROM sag_kontakter src
WHERE src.contact_id = %s AND src.deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM sag_kontakter dst
WHERE dst.contact_id = %s AND dst.sag_id = src.sag_id AND dst.deleted_at IS NULL
)
""",
(source_id, contact_id),
)
cursor.execute("UPDATE sag_kontakter SET contact_id = %s WHERE contact_id = %s", (contact_id, source_id))
direct_relations = (
("telefoni_opkald", "kontakt_id"),
("sms_messages", "kontakt_id"),
("tticket_email_metadata", "matched_contact_id"),
("tticket_tickets", "contact_id"),
("anydesk_sessions", "contact_id"),
("fedex_shipments", "contact_id"),
)
for table, column in direct_relations:
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
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(
"""
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
VALUES (%s, %s, %s::jsonb, %s::jsonb)
""",
(contact_id, source_id, json.dumps(source, default=str), json.dumps(moved)),
)
cursor.execute("DELETE FROM contacts WHERE id = %s", (source_id,))
conn.commit()
return {"success": True, "target_contact_id": contact_id, "merged_contact_id": source_id, "moved_relations": moved}
except HTTPException:
conn.rollback()
raise
except Exception as exc:
conn.rollback()
logger.error("Failed merging contact %s into %s: %s", source_id, contact_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Kontakterne kunne ikke flettes. Ingen ændringer blev gemt.")
finally:
release_db_connection(conn)
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies"""
@ -846,12 +1030,34 @@ async def get_contact_kontakt_history(contact_id: int, limit: int = Query(defaul
FROM sms_messages s
LEFT JOIN users u ON u.user_id = s.bruger_id
WHERE s.kontakt_id = %s
UNION ALL
SELECT
'merge' AS type,
h.id::text AS event_id,
h.merged_at AS happened_at,
NULL::text AS direction,
NULL::text AS number,
CONCAT(
'Flettet med ',
COALESCE(NULLIF(TRIM(CONCAT(
h.source_snapshot->>'first_name', ' ',
h.source_snapshot->>'last_name'
)), ''), 'kontakt #' || h.source_contact_id::text),
' (#', h.source_contact_id::text, ')'
) AS message,
NULL::int AS duration_sec,
NULL::text AS user_name,
'completed'::text AS sms_status
FROM contact_merge_history h
WHERE h.target_contact_id = %s
) z
ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (contact_id, contact_id, limit)) or []
rows = execute_query(query, (contact_id, contact_id, contact_id, limit)) or []
return {"items": rows}
except HTTPException:
raise

View File

@ -134,6 +134,9 @@
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light btn-sm" onclick="openMergeContactModal()" title="Flet en dubletkontakt ind i denne kontakt">
<i class="bi bi-intersect me-2"></i>Flet dublet
</button>
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('contact', contactId)" title="Opret vigtig information/advarsel om denne kontakt">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
</button>
@ -705,6 +708,48 @@
</div>
</div>
<!-- Merge duplicate contact modal -->
<div class="modal fade" id="mergeContactModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title"><i class="bi bi-intersect me-2"></i>Flet dubletkontakt</h5>
<div class="small text-muted">Denne kontakt beholdes som hovedkontakt. Den valgte dublet slettes efter flytning.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-primary py-2">
<strong>Hovedkontakt:</strong> <span id="mergeTargetName">-</span>
</div>
<label class="form-label fw-semibold" for="mergeContactSearch">Find dublet</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input id="mergeContactSearch" class="form-control" type="search" placeholder="Søg på navn, e-mail, telefon eller firma" autocomplete="off">
</div>
<div id="mergeContactResults" class="list-group mb-3"></div>
<div id="mergeContactPreview" class="d-none">
<div class="border rounded p-3">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><div class="small text-muted">Dublet der fjernes</div><strong id="mergeSourceName"></strong><div class="small text-muted" id="mergeSourceDetails"></div></div>
<span class="badge text-bg-warning">Kan ikke fortrydes direkte</span>
</div>
<div class="small fw-semibold mb-2">Relateret information der flyttes</div>
<div id="mergeRelationCounts" class="d-flex flex-wrap gap-2"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button id="mergeContactSubmit" type="button" class="btn btn-danger" onclick="submitContactMerge()" disabled>
<i class="bi bi-intersect me-2"></i>Flet kontakter
</button>
</div>
</div>
</div>
</div>
<!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@ -802,11 +847,21 @@ let contactData = null;
let primaryCustomerId = null;
let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
document.addEventListener('DOMContentLoaded', () => {
loadContact();
loadCompaniesForSelect();
const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) {
mergeSearch.addEventListener('input', () => {
clearTimeout(mergeSearchTimer);
mergeSearchTimer = setTimeout(() => searchMergeContacts(mergeSearch.value), 250);
});
}
// Load companies when tab is shown
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
loadCompanies();
@ -1882,7 +1937,7 @@ function renderKontaktHistoryTable() {
if (!filteredItems.length) {
const msg = kontaktHistoryItems.length
? 'Ingen hændelser matcher filteret'
: 'Ingen opkald eller SMS fundet';
: 'Ingen kontakthistorik fundet';
container.innerHTML = `<div class="text-muted text-center py-5">${msg}</div>`;
return;
}
@ -1913,13 +1968,17 @@ function renderKontaktHistoryRow(item) {
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
const typeBadge = item.type === 'sms'
? '<span class="badge bg-primary-subtle text-primary-emphasis">SMS</span>'
: item.type === 'merge'
? '<span class="badge bg-info-subtle text-info-emphasis">Fletning</span>'
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-')
: item.type === 'merge'
? 'Gennemført'
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
const message = item.type === 'sms'
const message = item.type === 'sms' || item.type === 'merge'
? escapeHtml(item.message || '-')
: '-';
@ -2327,6 +2386,106 @@ async function saveEditContact() {
}
}
function contactMergeName(contact) {
if (!contact) return 'Ukendt kontakt';
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|| contact.email
|| `Kontakt #${contact.id}`;
}
function openMergeContactModal() {
mergeSourceContactId = null;
const targetName = contactMergeName(contactData);
const automaticSearch = `${contactData?.first_name || ''} ${contactData?.last_name || ''}`.trim();
document.getElementById('mergeTargetName').textContent = targetName;
document.getElementById('mergeContactSearch').value = automaticSearch;
document.getElementById('mergeContactResults').innerHTML = '<div class="text-muted small">Søger efter mulige dubletter…</div>';
document.getElementById('mergeContactPreview').classList.add('d-none');
document.getElementById('mergeContactSubmit').disabled = true;
const modalElement = document.getElementById('mergeContactModal');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
modalElement.addEventListener('shown.bs.modal', () => document.getElementById('mergeContactSearch').focus(), { once: true });
if (automaticSearch.length >= 2) {
searchMergeContacts(automaticSearch);
}
}
async function searchMergeContacts(query) {
const results = document.getElementById('mergeContactResults');
const value = query.trim();
if (value.length < 2) {
results.innerHTML = '<div class="text-muted small">Skriv mindst 2 tegn.</div>';
return;
}
results.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger…</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(value)}&limit=20`);
if (!response.ok) throw new Error('Søgningen fejlede');
const data = await response.json();
const contacts = (data.contacts || []).filter(contact => Number(contact.id) !== contactId);
if (!contacts.length) {
results.innerHTML = '<div class="text-muted small">Ingen andre kontakter fundet.</div>';
return;
}
results.innerHTML = contacts.map(contact => {
const companies = (contact.company_names || []).join(', ') || 'Intet firma';
const details = [contact.email, contact.mobile || contact.phone, companies].filter(Boolean).join(' · ');
return `<button type="button" class="list-group-item list-group-item-action" onclick="selectMergeContact(${Number(contact.id)})">
<div class="fw-semibold">${escapeHtml(contactMergeName(contact))}</div>
<div class="small text-muted">#${Number(contact.id)} · ${escapeHtml(details)}</div>
</button>`;
}).join('');
} catch (error) {
results.innerHTML = `<div class="text-danger small">${escapeHtml(error.message)}</div>`;
}
}
async function selectMergeContact(sourceId) {
try {
const response = await fetch(`/api/v1/contacts/${contactId}/merge-preview?source_contact_id=${sourceId}`);
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente fletteoversigt');
mergeSourceContactId = sourceId;
document.getElementById('mergeSourceName').textContent = contactMergeName(data.source);
document.getElementById('mergeSourceDetails').textContent = [
`Kontakt #${data.source.id}`,
data.source.email,
data.source.mobile || data.source.phone,
].filter(Boolean).join(' · ');
const relevant = (data.relations || []).filter(item => Number(item.count) > 0);
document.getElementById('mergeRelationCounts').innerHTML = relevant.length
? relevant.map(item => `<span class="badge text-bg-light border">${escapeHtml(item.label)}: ${Number(item.count)}</span>`).join('')
: '<span class="text-muted small">Ingen direkte relationer fundet.</span>';
document.getElementById('mergeContactPreview').classList.remove('d-none');
document.getElementById('mergeContactSubmit').disabled = false;
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
async function submitContactMerge() {
if (!mergeSourceContactId) return;
const sourceName = document.getElementById('mergeSourceName').textContent;
if (!confirm(`Flet ${sourceName} ind i ${contactMergeName(contactData)}?\n\nSager, firmaer, e-mails, opkald og øvrige relationer flyttes. Dubletkontakten slettes.`)) return;
const button = document.getElementById('mergeContactSubmit');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_contact_id: mergeSourceContactId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kontakterne kunne ikke flettes');
bootstrap.Modal.getInstance(document.getElementById('mergeContactModal'))?.hide();
alert('Kontakterne er flettet. Alle relationer er samlet på hovedkontakten.');
window.location.reload();
} catch (error) {
button.disabled = false;
alert(`Fejl: ${error.message}`);
}
}
function getInitials(firstName, lastName) {
if (!firstName && !lastName) return '?';
const first = firstName ? firstName[0] : '';

View File

@ -189,6 +189,7 @@ async def search_sag(q: str):
s.beskrivelse,
s.status,
s.created_at,
(s.deleted_at IS NOT NULL) AS is_archived,
s.customer_id,
c.name as customer_name,
ARRAY(
@ -202,8 +203,7 @@ async def search_sag(q: str):
) AS buzzwords
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND (
WHERE (
CAST(s.id AS TEXT) ILIKE %s OR
s.titel ILIKE %s OR
s.beskrivelse ILIKE %s OR

View File

@ -965,7 +965,7 @@
<script>
(() => {
const kpiLabels = {
open_cases: 'Aabne sager',
open_cases: 'Åbne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
@ -1362,7 +1362,7 @@
}
const call = state.activeCalls[0];
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = [];
if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name);
@ -1381,7 +1381,7 @@
list.innerHTML = state.activeCalls.map((call) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
@ -1736,7 +1736,7 @@
function renderMotionBadge() {
if (state.cameraMotion && state.cameraMotion.motion) {
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');

View File

@ -965,7 +965,7 @@
<script>
(() => {
const kpiLabels = {
open_cases: 'Aabne sager',
open_cases: 'Åbne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
@ -1362,7 +1362,7 @@
}
const call = state.activeCalls[0];
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = [];
if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name);
@ -1381,7 +1381,7 @@
list.innerHTML = state.activeCalls.map((call) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
@ -1736,7 +1736,7 @@
function renderMotionBadge() {
if (state.cameraMotion && state.cameraMotion.motion) {
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');

View File

@ -1935,7 +1935,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body mc-day-modal-body">
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vaelg en sag fra listen.</div>
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vælg en sag fra listen.</div>
<div>
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
@ -2070,7 +2070,7 @@
const projectOnlyMode = {{ 'true' if project_only else 'false' }};
const kpiLabels = {
open_cases: 'Aabne sager',
open_cases: 'Åbne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
@ -2956,7 +2956,7 @@
}
const call = state.activeCalls[0];
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = [];
if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name);
@ -2975,7 +2975,7 @@
list.innerHTML = state.activeCalls.map((call) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
@ -3571,7 +3571,7 @@
const userSelect = document.getElementById(`assignUser-${id}`);
const userId = toOptionalInt(userSelect?.value);
if (userId === null) {
alert('Vaelg en medarbejder foerst.');
alert('Vælg en medarbejder først.');
return;
}
@ -3587,7 +3587,7 @@
const groupSelect = document.getElementById(`assignGroup-${id}`);
const groupId = toOptionalInt(groupSelect?.value);
if (groupId === null) {
alert('Vaelg en gruppe foerst.');
alert('Vælg en gruppe først.');
return;
}
@ -3768,7 +3768,7 @@
function renderMotionBadge() {
if (state.cameraMotion && state.cameraMotion.motion) {
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');

View File

@ -447,7 +447,7 @@
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overførsel til e-conomic)');
if (!ok) return;
try {
@ -462,7 +462,7 @@
const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
const nextStep = result.orders_url ? `\n\nÅbn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`

View File

@ -1,4 +1,5 @@
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@ -854,22 +855,25 @@ def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"context_key": "global",
"global": [
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
{"id": "new_case", "label": "Ny sag", "action": "/sag/new", "icon": "bi-plus-circle"},
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
{"id": "start_timer", "label": "Skift/start timer", "command": "switch_timer", "icon": "bi-stopwatch"},
{"id": "log_time", "label": "Tidsoversigt", "action": "/timetracking/registrations", "icon": "bi-clock-history"},
{"id": "add_note", "label": "Ny personlig note", "command": "open_notes", "icon": "bi-journal-plus"},
],
"context": [],
}
if normalized.startswith("/sag"):
case_match = re.match(r"^/sag/(\d+)(?:/v3)?/?$", normalized)
if case_match:
case_id = int(case_match.group(1))
payload["context_key"] = "sag"
payload["case_id"] = case_id
payload["context"] = [
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
{"id": "case_mail", "label": "Mail", "action": "/emails"},
{"id": "case_relation", "label": "Relation", "action": "/customers"},
{"id": "case_tag", "label": "Tag", "action": "/tags"},
{"id": "case_time", "label": "Registrér tid", "command": "case_add:time", "icon": "bi-clock"},
{"id": "case_note", "label": "Tilføj kommentar", "command": "case_add:note", "icon": "bi-chat-left-text"},
{"id": "case_mail", "label": "Send mail", "command": "case_add:email", "icon": "bi-envelope"},
{"id": "case_subscription", "label": "Abonnement", "command": "case_add:subscription", "icon": "bi-arrow-repeat"},
]
elif normalized.startswith("/hardware"):
payload["context_key"] = "hardware"

View File

@ -502,12 +502,12 @@
<div class="calendar-shell fade-up">
<div class="calendar-toolbar">
<div class="view-buttons" id="viewButtons">
<button type="button" data-view="dayGridMonth" class="active">Maaned</button>
<button type="button" data-view="dayGridMonth" class="active">Måned</button>
<button type="button" data-view="timeGridWeek">Uge</button>
<button type="button" data-view="timeGridDay">Dag</button>
<button type="button" data-view="listWeek">Agenda</button>
</div>
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div>
<div class="status-bar" id="rangeLabel">Indlæser periode...</div>
</div>
<div id="calendar"></div>
<div class="calendar-legend">

View File

@ -883,7 +883,7 @@
<div class="row g-3 mb-3">
<div class="col-md-6 col-xl-3">
<div class="stat-tile">
<div class="stat-label">Total omsaetning</div>
<div class="stat-label">Total omsætning</div>
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.total_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
<div class="stat-helper">Fra alle ordrelinjer pa dette asset</div>
</div>
@ -958,7 +958,7 @@
<th>Dato</th>
<th>Titel</th>
<th>Status</th>
<th class="text-end">Beloeb</th>
<th class="text-end">Beløb</th>
</tr>
</thead>
<tbody>
@ -1054,13 +1054,13 @@
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
</div>
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3">
Vaelg kunde og sag for at se hvad der bliver oprettet.
Vælg kunde og sag for at se, hvad der bliver oprettet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<select class="form-select" id="quickRentCustomerId" required>
<option value="">-- Vaelg kunde --</option>
<option value="">-- Vælg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}">{{ customer.navn }}</option>
{% endfor %}
@ -1480,11 +1480,11 @@
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er paakraevet.');
alert('Kunde, sag og startdato er påkrævet.');
return;
}
if (operationsMonthlyPrice <= 0) {
alert('Drift pr. maned skal vaere over 0.');
alert('Drift pr. måned skal være over 0.');
return;
}
@ -1548,14 +1548,14 @@
if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Vaelg kunde og sag for at se hvad der bliver oprettet.';
infoEl.textContent = 'Vælg kunde og sag for at se, hvad der bliver oprettet.';
submitBtn.disabled = false;
return;
}
try {
infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Tjekker abonnement paa sagen...';
infoEl.textContent = 'Tjekker abonnement på sagen...';
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json();
@ -1578,7 +1578,7 @@
} catch (error) {
submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3';
infoEl.textContent = `Preview fejl: ${error.message}. Du kan stadig prove at oprette.`;
infoEl.textContent = `Fejl i forhåndsvisning: ${error.message}. Du kan stadig prøve at oprette.`;
}
}

View File

@ -167,7 +167,7 @@
<div class="section-card" id="devicesSection">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<div class="status-pill" id="deviceStatus">Ingen data indlæst</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="runOnePcFullTest()">Test 1 PC (ALT)</button>
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
@ -219,7 +219,7 @@
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
</div>
<div id="contactResults" class="contact-results"></div>
<div id="contactHint" class="contact-muted">Tip: Skriv 2+ tegn og tryk Enter for at vaelge den forste.</div>
<div id="contactHint" class="contact-muted">Tip: Skriv mindst 2 tegn, og tryk Enter for at vælge den første.</div>
</div>
<div class="mb-3">
@ -365,7 +365,7 @@
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
<div class="mt-3">
<label class="form-label small">Vaelg kontakt (ejer)</label>
<label class="form-label small">Vælg kontakt (ejer)</label>
<input type="text" class="form-control form-control-sm inline-contact-search" data-index="${index}" placeholder="Sog kontakt..." autocomplete="off">
<div id="inlineResults-${index}" class="inline-results"></div>
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
@ -409,7 +409,7 @@
async function searchInlineContacts(query, index) {
const results = document.getElementById(`inlineResults-${index}`);
if (!results) return;
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
@ -571,10 +571,10 @@
const query = document.getElementById('contactSearch').value.trim();
const results = document.getElementById('contactResults');
if (!query) {
results.innerHTML = '<div class="p-2 text-muted">Indtast soegning.</div>';
results.innerHTML = '<div class="p-2 text-muted">Indtast søgning.</div>';
return;
}
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
if (!response.ok) {

View File

@ -1136,7 +1136,7 @@
const payload = getFormPayload();
if (!payload.name) {
alert('Navn er paakraevet.');
alert('Navn er påkrævet.');
return;
}

View File

@ -196,6 +196,7 @@ def _fetch_closed_case_statuses() -> list[str]:
async def sager_liste(
request: Request,
status: str = Query(None),
priority: str = Query(None),
tag: str = Query(None),
customer_id: str = Query(None),
ansvarlig_bruger_id: str = Query(None),
@ -278,6 +279,7 @@ async def sager_liste(
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_status = str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all":
pass
elif normalized_status:
@ -287,6 +289,9 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses))
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
params.extend(closed_statuses)
if normalized_priority:
query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
params.append(normalized_priority)
if customer_id_int:
query += " AND s.customer_id = %s"
params.append(customer_id_int)
@ -350,6 +355,9 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses))
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
fallback_params.extend(closed_statuses)
if normalized_priority:
fallback_query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
fallback_params.append(normalized_priority)
if customer_id_int:
fallback_query += " AND s.customer_id = %s"
fallback_params.append(customer_id_int)
@ -429,6 +437,7 @@ async def sager_liste(
"statuses": status_options,
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_priority": normalized_priority,
"current_tag": tag,
"include_deferred": include_deferred,
"toggle_include_deferred_url": toggle_include_deferred_url,
@ -450,6 +459,7 @@ async def sager_liste(
"statuses": _fetch_case_status_options(),
"all_tags": [],
"current_status": status,
"current_priority": str(priority or "").strip().lower(),
"current_tag": tag,
"include_deferred": include_deferred,
"toggle_include_deferred_url": str(request.url),

View File

@ -1020,7 +1020,8 @@
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
abonnement: '🔁 Abonnement'
};
function updateCaseTypeSections() {
@ -1109,6 +1110,7 @@
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);

File diff suppressed because it is too large Load Diff

View File

@ -198,6 +198,7 @@
<option value="ordre" {% if (case.template_key or case.type) == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
<option value="projekt" {% if (case.template_key or case.type) == 'projekt' %}selected{% endif %}>📁 Projekt</option>
<option value="service" {% if (case.template_key or case.type) == 'service' %}selected{% endif %}>🛠️ Service</option>
<option value="abonnement" {% if (case.template_key or case.type) == 'abonnement' %}selected{% endif %}>🔁 Abonnement</option>
</select>
</div>
@ -260,6 +261,7 @@
if (!Array.isArray(types) || types.length === 0) return;
const typeSet = new Set(types);
typeSet.add('abonnement');
if (currentType) {
typeSet.add(currentType);
}

View File

@ -1230,7 +1230,7 @@
const options = Array.from(typeFilter.options || []);
if (options.length === 0) {
typeFilterCheckboxList.innerHTML = '<span class="type-filter-empty">Ingen typer fundet</span>';
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgaengelige';
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgængelige';
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
return;
}

View File

@ -190,7 +190,7 @@ let currentCardStatus = '';
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];

View File

@ -107,7 +107,7 @@
</div>
<div class="mb-3">
<label class="form-label">Note</label>
<input type="text" class="form-control" id="priceNote" placeholder="Aarsag til prisændring">
<input type="text" class="form-control" id="priceNote" placeholder="Årsag til prisændring">
</div>
<div class="mb-3">
<label class="form-label">Opdateret af</label>
@ -117,24 +117,24 @@
<div class="small mt-3" id="priceUpdateMessage"></div>
</div>
<div class="product-card mt-3">
<h5 class="mb-3">Opdater leverandoer</h5>
<h5 class="mb-3">Opdater leverandør</h5>
<div class="mb-3">
<label class="form-label">Leverandoer</label>
<input type="text" class="form-control" id="supplierName" placeholder="Leverandoer navn">
<label class="form-label">Leverandør</label>
<input type="text" class="form-control" id="supplierName" placeholder="Leverandørnavn">
</div>
<div class="mb-3">
<label class="form-label">Leverandoer pris</label>
<label class="form-label">Leverandørpris</label>
<input type="number" class="form-control" id="supplierPrice" step="0.01" min="0">
</div>
<div class="mb-3">
<label class="form-label">Note</label>
<input type="text" class="form-control" id="supplierNote" placeholder="Aarsag til ændring">
<input type="text" class="form-control" id="supplierNote" placeholder="Årsag til ændring">
</div>
<div class="mb-3">
<label class="form-label">Opdateret af</label>
<input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn">
</div>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandoer</button>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandør</button>
<div class="small mt-3" id="supplierUpdateMessage"></div>
</div>
</div>
@ -144,7 +144,7 @@
<div class="table-responsive">
<table class="table table-sm product-table mb-0">
<tbody id="productInfoBody">
<tr><td class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -164,7 +164,7 @@
</tr>
</thead>
<tbody id="priceHistoryBody">
<tr><td colspan="6" class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td colspan="6" class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -186,7 +186,7 @@
</tr>
</thead>
<tbody id="salesHistoryBody">
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td colspan="7" class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -218,7 +218,7 @@
</tr>
</thead>
<tbody id="supplierListBody">
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
<tr><td colspan="7" class="text-center product-muted py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -256,7 +256,7 @@
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilfoej/Opdater</button>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
</div>
<div class="col-12">
<div class="small" id="supplierListMessage"></div>
@ -356,9 +356,9 @@ function renderProductInfo(product) {
['Status', product.status],
['SKU intern', product.sku_internal],
['Producent', product.manufacturer],
['Leverandoer', product.supplier_name],
['Leverandoer SKU', product.supplier_sku],
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
['Leverandør', product.supplier_name],
['Leverandør-SKU', product.supplier_sku],
['Leverandørpris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : null],
@ -399,8 +399,8 @@ async function loadProductDetail() {
document.getElementById('productPrice').textContent = product.sales_price != null ? formatCurrency(product.sales_price) : '-';
document.getElementById('productSku').textContent = product.sku_internal || '-';
document.getElementById('productSupplierPrice').textContent = product.supplier_price != null
? `Leverandoer: ${formatCurrency(product.supplier_price)}`
: 'Leverandoer pris: -';
? `Leverandør: ${formatCurrency(product.supplier_price)}`
: 'Leverandørpris: -';
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
document.getElementById('supplierName').value = product.supplier_name || '';
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
@ -409,7 +409,7 @@ async function loadProductDetail() {
renderProductInfo(product);
prefillSupplierSku(product);
} catch (e) {
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
setMessage(e.message || 'Fejl ved indlæsning', 'text-danger');
}
}
@ -426,7 +426,7 @@ async function loadPriceHistory() {
tbody.innerHTML = history.map(entry => `
<tr>
<td>${formatDate(entry.changed_at)}</td>
<td>${entry.price_type === 'supplier_price' ? 'Leverandoer' : 'Salgspris'}</td>
<td>${entry.price_type === 'supplier_price' ? 'Leverandør' : 'Salgspris'}</td>
<td>${entry.old_price != null ? formatCurrency(entry.old_price) : '-'}</td>
<td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td>
<td>${escapeHtml(entry.note || '-')}</td>
@ -693,10 +693,10 @@ async function submitSupplierUpdate() {
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Leverandoer opdatering fejlede');
throw new Error(error.detail || 'Opdatering af leverandør fejlede');
}
await res.json();
setSupplierMessage('Leverandoer opdateret', 'text-success');
setSupplierMessage('Leverandør opdateret', 'text-success');
await loadProductDetail();
await loadPriceHistory();
} catch (e) {

View File

@ -210,7 +210,7 @@
<div class="products-search">
<div class="input-group" style="min-width: 260px;">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter eller EAN/strengkode...">
<input type="text" class="form-control" id="localSearchQuery" placeholder="Søg lokale produkter eller EAN/stregkode...">
</div>
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
<option value="active" selected>Aktiv</option>
@ -232,10 +232,10 @@
<option value="false">Nej</option>
</select>
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
<i class="bi bi-search"></i> Soeg
<i class="bi bi-search"></i> Søg
</button>
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)
<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)
</button>
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
<i class="bi bi-x"></i> Nulstil
@ -256,7 +256,7 @@
<span class="text-muted small" id="localProductsPageInfo"></span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" id="localPrevPage" onclick="changeLocalPage(-1)">Forrige</button>
<button class="btn btn-outline-secondary" id="localNextPage" onclick="changeLocalPage(1)">Naeste</button>
<button class="btn btn-outline-secondary" id="localNextPage" onclick="changeLocalPage(1)">Næste</button>
</div>
</div>
</div>
@ -277,7 +277,7 @@
<tbody id="productsBody">
<tr>
<td colspan="6" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
</td>
</tr>
</tbody>
@ -298,14 +298,14 @@
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-6">
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Soeg efter produktnavn...">
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Søg efter produktnavn...">
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Supplier code">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" onclick="searchGatewayProducts(true)">
<i class="bi bi-search"></i> Soeg
<i class="bi bi-search"></i> Søg
</button>
</div>
<div class="col-md-4">
@ -317,7 +317,7 @@
<div class="col-md-4 d-flex align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="gatewayModalInStock">
<label class="form-check-label" for="gatewayModalInStock">Kun paa lager</label>
<label class="form-check-label" for="gatewayModalInStock">Kun på lager</label>
</div>
</div>
<div class="col-md-4">
@ -328,7 +328,7 @@
</div>
</div>
<div id="gatewayResults">
<div class="text-center text-muted py-4">Soeg efter produkter for at importere.</div>
<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>
</div>
</div>
</div>
@ -352,13 +352,13 @@
<div class="col-md-6">
<label class="form-label">Type</label>
<select class="form-select" id="productType">
<option value="">- Vaelg type -</option>
<option value="">- Vælg type -</option>
<option value="hardware">Hardware</option>
<option value="service">Service</option>
<option value="subscription">Abonnement</option>
<option value="bundle">Bundle</option>
</select>
<div class="form-text">Vaelg produkttype for korrekt kategorisering.</div>
<div class="form-text">Vælg produkttype for korrekt kategorisering.</div>
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
@ -397,9 +397,9 @@
<label class="form-label">Faktureringsinterval</label>
<select class="form-select" id="productBillingPeriod">
<option value="">-</option>
<option value="monthly">Maaned</option>
<option value="monthly">Måned</option>
<option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option>
<option value="yearly">År</option>
<option value="one_time">Engang</option>
</select>
</div>
@ -424,7 +424,7 @@
</div>
<div class="col-12">
<div class="alert alert-light small">
Avancerede felter kan tilfoejes senere via API.
Avancerede felter kan tilføjes senere via API.
</div>
</div>
</div>
@ -461,7 +461,7 @@ async function loadProducts() {
document.getElementById('productsBody').innerHTML = `
<tr><td colspan="6" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
@ -609,14 +609,14 @@ async function searchAndCreateByGatewayCode() {
const code = queryInput ? queryInput.value.trim() : '';
if (!code) {
alert('Skriv EAN eller strengkode i soegefeltet foerst');
alert('Skriv EAN eller stregkode i søgefeltet først');
return;
}
const oldHtml = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Soeger...';
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Søger...';
}
try {
@ -652,7 +652,7 @@ async function searchAndCreateByGatewayCode() {
} finally {
if (button) {
button.disabled = false;
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)';
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)';
}
}
}
@ -751,9 +751,9 @@ function formatCurrency(amount) {
function formatInterval(interval) {
const map = {
'monthly': 'Maaned',
'monthly': 'Måned',
'quarterly': 'Kvartal',
'yearly': 'Aar',
'yearly': 'År',
'one_time': 'Engang'
};
return map[interval] || interval || '-';
@ -782,7 +782,7 @@ async function createProduct() {
};
if (!payload.name) {
alert('Navn er paakraevet');
alert('Navn er påkrævet');
return;
}
@ -810,7 +810,7 @@ function openGatewaySearchModal(autoSearch = false) {
document.getElementById('gatewayModalMinPrice').value = '';
document.getElementById('gatewayModalMaxPrice').value = '';
document.getElementById('gatewayModalInStock').checked = false;
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Soeg efter produkter for at importere.</div>';
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>';
new bootstrap.Modal(document.getElementById('gatewaySearchModal')).show();
if (autoSearch) {
searchGatewayProducts(true);
@ -836,7 +836,7 @@ async function searchGatewayProducts(fromModal = false) {
const manufacturer = manufacturerInput ? manufacturerInput.value.trim() : '';
const inStock = inStockInput ? inStockInput.checked : false;
if (!q && !supplier) {
alert('Angiv soegeord eller supplier code');
alert('Angiv søgeord eller leverandørkode');
return;
}
@ -851,14 +851,14 @@ async function searchGatewayProducts(fromModal = false) {
const resultsContainer = document.getElementById('gatewayResults');
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>Soege...</div>';
resultsContainer.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`);
if (!res.ok) {
const error = await res.text();
throw new Error(error || 'Gateway soegning fejlede');
throw new Error(error || 'Gateway-søgning fejlede');
}
const data = await res.json();
const products = Array.isArray(data.products) ? data.products : [];
@ -866,7 +866,7 @@ async function searchGatewayProducts(fromModal = false) {
} catch (e) {
console.error(e);
if (resultsContainer) {
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved soegning'}</div>`;
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved søgning'}</div>`;
}
}
}
@ -1041,7 +1041,7 @@ function renderGatewayResults() {
</select>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" ${page <= 1 ? 'disabled' : ''} onclick="goGatewayPage(-1)">Forrige</button>
<button class="btn btn-outline-secondary" ${page >= totalPages ? 'disabled' : ''} onclick="goGatewayPage(1)">Naeste</button>
<button class="btn btn-outline-secondary" ${page >= totalPages ? 'disabled' : ''} onclick="goGatewayPage(1)">Næste</button>
</div>
</div>
</div>
@ -1132,7 +1132,7 @@ function importGatewayProductFromAttr(button) {
const product = JSON.parse(decodeURIComponent(payload));
importGatewayProduct(product);
} catch (e) {
alert('Kunne ikke laese produktdata');
alert('Kunne ikke læse produktdata');
}
}

View File

@ -285,7 +285,7 @@ async def get_setting(key: str):
seed_query,
(
"case_types",
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
"system",
"Sags-typer",
"json",

View File

@ -228,7 +228,7 @@
lastError = errMsg;
} catch (err) {
attempts.push(`${url} -> ERR`);
lastError = err.message || 'Netvaerksfejl';
lastError = err.message || 'Netværksfejl';
}
}
@ -335,7 +335,7 @@
lastError = errMsg;
} catch (err) {
attempts.push(`${url} -> ERR`);
lastError = err.message || 'Netvaerksfejl';
lastError = err.message || 'Netværksfejl';
}
}

View File

@ -635,7 +635,7 @@
<select class="form-select" id="taskTemplateSourceFilter" onchange="loadTaskTemplates()">
<option value="all">Alle</option>
<option value="company">Firma</option>
<option value="global">Faelles</option>
<option value="global">Fælles</option>
<option value="internal">Intern</option>
</select>
</div>
@ -647,7 +647,7 @@
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="brugerandring">Brugerændring</option>
<option value="andet">Andet</option>
</select>
</div>
@ -692,11 +692,11 @@
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="fw-bold mb-0" id="taskTemplateItemsTitle">Template-opgaver</h6>
<small class="text-muted" id="taskTemplateItemsSubtitle">Vaelg en template for at redigere items</small>
<small class="text-muted" id="taskTemplateItemsSubtitle">Vælg en template for at redigere elementer</small>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="taskTemplateAddItemBtn" onclick="openTaskTemplateItemModal()" disabled>
<i class="bi bi-plus-lg me-1"></i>Tilfoej
<i class="bi bi-plus-lg me-1"></i>Tilføj
</button>
<button class="btn btn-sm btn-outline-secondary" id="taskTemplateBulkBuilderBtn" onclick="openTaskTemplateBulkBuilderModal()" disabled>
<i class="bi bi-magic me-1"></i>Builder
@ -1078,7 +1078,7 @@
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
<small class="text-muted">Overvåger, om alle arkiverede tickets er synkroniseret (kildeantal mod lokal database)</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
@ -1124,7 +1124,7 @@
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
<small class="text-muted" id="archivedStatusHint">Polling er aktiv, når Sync-fanen er åben.</small>
</div>
</div>
</div>
@ -2114,7 +2114,7 @@ let sagTestReportsCache = [];
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
@ -2600,9 +2600,9 @@ function renderDriftConnectors() {
<label class="form-label fw-semibold">Ignorer varetekster</label>
<div class="input-group">
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilføj</button>
</div>
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
<div class="form-text">Matcher på varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
</div>
</div>
@ -2648,7 +2648,7 @@ async function loadSettings() {
try {
const response = await fetch('/api/v1/settings', { credentials: 'include' });
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese indstillinger'));
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse indstillinger'));
}
const payload = await response.json();
allSettings = Array.isArray(payload) ? payload : [];
@ -2744,11 +2744,11 @@ function addTimeMultiplierPreset() {
const multiplier = Number(multiplierInput.value || 0);
if (!label) {
showNotification('Navn er paakraevet', 'error');
showNotification('Navn er påkrævet', 'error');
return;
}
if (!Number.isFinite(multiplier) || multiplier <= 0) {
showNotification('Multiplier skal vaere stoerre end 0', 'error');
showNotification('Multiplikatoren skal være større end 0', 'error');
return;
}
@ -4111,7 +4111,7 @@ async function loadAdminUsers() {
try {
const response = await fetch('/api/v1/admin/users');
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese brugere'));
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse brugere'));
}
usersCache = await response.json();
displayUsers(usersCache);
@ -4119,7 +4119,7 @@ async function loadAdminUsers() {
} catch (error) {
console.error('Error loading users:', error);
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlaese brugere')}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlæse brugere')}</td></tr>`;
}
}
@ -6274,7 +6274,7 @@ async function loadPipelineStages() {
try {
const response = await fetch('/api/v1/pipeline/stages');
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese pipeline stages'));
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse pipeline-trin'));
}
const payload = await response.json();
const stages = Array.isArray(payload) ? payload : [];
@ -6698,14 +6698,14 @@ document.addEventListener('DOMContentLoaded', () => {
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="brugerandring">Brugerændring</option>
<option value="andet" selected>Andet</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Firma (kun firma-type)</label>
<select id="taskTemplateCompany" class="form-select">
<option value="">Vaelg firma...</option>
<option value="">Vælg firma...</option>
</select>
</div>
</div>
@ -6728,7 +6728,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilfoej item</h5>
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilføj element</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@ -6849,21 +6849,21 @@ const TASK_TEMPLATE_BUILDER_PRESETS = {
onboarding: [
'task|0|Velkomstmail til bruger|Send velkomstmail med kontaktinfo og forventet plan',
'task|0|Bestil hardware|Bestil standard udstyr til ny medarbejder',
'task|1|Klargoer kontoer og licenser|Opsaet M365, VPN, sikkerhedsprofil og grupper',
'task|2|Planlaeg introduktionsmoede|Book intro med leder og support',
'subcase|0|Onboarding undersag: adgange|Opfoelgning paa adgangsrequests og godkendelser'
'task|1|Klargør konti og licenser|Opsæt M365, VPN, sikkerhedsprofil og grupper',
'task|2|Planlæg introduktionsmøde|Book introduktion med leder og support',
'subcase|0|Onboarding-undersag: adgange|Opfølgning på adgangsanmodninger og godkendelser'
],
offboarding: [
'task|0|Bekraeft fratraedelsesdato|Afstem sidste arbejdsdag med leder/HR',
'task|0|Bekræft fratrædelsesdato|Afstem sidste arbejdsdag med leder/HR',
'task|0|Luk adgange|Deaktiver M365, VPN og eksterne konti',
'task|1|Indsaml udstyr|Koordiner retur af laptop, mobil og noegler',
'task|1|Indsaml udstyr|Koordinér returnering af laptop, mobil og nøgler',
'subcase|0|Offboarding undersag: dokumentation|Saml audit-noter og afslutningsdokumentation'
],
hardware: [
'task|0|Afdaek behov|Afklar krav til model, tilbehoer og levering',
'task|0|Indhent pris|Kontroller pris og leveringstid hos leverandoer',
'task|0|Afdæk behov|Afklar krav til model, tilbehør og levering',
'task|0|Indhent pris|Kontrollér pris og leveringstid hos leverandør',
'task|1|Bestil hardware|Placer ordren og registrer ordrenummer',
'task|3|Levering og klargoering|Klargoer enhed og informer bruger'
'task|3|Levering og klargøring|Klargør enhed, og informér brugeren'
]
};
@ -6874,7 +6874,7 @@ function getTaskTemplateBuilderModal() {
function openTaskTemplateBulkBuilderModal() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
@ -6964,7 +6964,7 @@ function parseTaskTemplateBuilderLines(rawText, defaults = {}) {
async function importTaskTemplateBulkItems() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
@ -7040,7 +7040,7 @@ function taskTemplateCategoryLabel(category) {
offboarding: 'Offboarding',
simkort: 'Mobil / SIM-kort',
hardwarebestilling: 'Hardwarebestilling',
brugerandring: 'Brugeraendring',
brugerandring: 'Brugerændring',
andet: 'Andet'
};
return labels[category] || category || '-';
@ -7063,7 +7063,7 @@ async function loadTaskTemplateCustomers() {
if (!filterSelect || !modalSelect) return;
filterSelect.innerHTML = '<option value="">Alle kunder</option>';
modalSelect.innerHTML = '<option value="">Vaelg firma...</option>';
modalSelect.innerHTML = '<option value="">Vælg firma...</option>';
customers.forEach((customer) => {
const label = `${customer.name} (#${customer.id})`;
@ -7126,7 +7126,7 @@ async function loadTaskTemplates() {
`).join('');
} catch (error) {
console.error('Error loading task templates:', error);
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlaesning')}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlæsning')}</td></tr>`;
}
}
@ -7173,12 +7173,12 @@ async function saveTaskTemplateSettings() {
};
if (!payload.name) {
alert('Navn er paakraevet');
alert('Navn er påkrævet');
return;
}
if (templateType === 'company' && !payload.customer_id) {
alert('Vaelg firma for firma-template');
alert('Vælg firma til firmatemplate');
return;
}
@ -7215,7 +7215,7 @@ async function deactivateTaskTemplateSettings(templateId) {
if (selectedTaskTemplateId === templateId) {
selectedTaskTemplateId = null;
document.getElementById('taskTemplateItemsTitle').textContent = 'Template-opgaver';
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vaelg en template for at redigere items';
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vælg en template for at redigere elementer';
document.getElementById('taskTemplateItemsList').innerHTML = '<div class="list-group-item text-muted small">Ingen template valgt.</div>';
document.getElementById('taskTemplateAddItemBtn').disabled = true;
document.getElementById('taskTemplateBulkBuilderBtn').disabled = true;
@ -7295,13 +7295,13 @@ async function loadTaskTemplateItems(templateId) {
function openTaskTemplateItemModal() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
document.getElementById('taskTemplateItemForm').reset();
document.getElementById('taskTemplateItemId').value = '';
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilfoej item';
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilføj element';
document.getElementById('taskTemplateItemRequired').checked = true;
document.getElementById('taskTemplateItemActive').checked = true;
@ -7329,7 +7329,7 @@ function editTaskTemplateItem(item) {
async function saveTaskTemplateItem() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
alert('Vælg en template først');
return;
}
@ -7348,7 +7348,7 @@ async function saveTaskTemplateItem() {
};
if (!payload.title) {
alert('Titel er paakraevet');
alert('Titel er påkrævet');
return;
}

View File

@ -22,7 +22,7 @@
--frame-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
--border-radius: 12px;
--bottom-bar-height: 50px;
--bottom-bar-expanded-height: 50vh;
--bottom-bar-expanded-height: 68vh;
--bottom-bar-zindex: 1030;
}
@ -53,7 +53,7 @@
}
body.bottom-bar-visible.bottom-bar-expanded {
padding-bottom: calc(var(--bottom-bar-height) + 52vh);
padding-bottom: calc(var(--bottom-bar-height) + var(--bottom-bar-expanded-height));
}
.global-bottom-bar {
@ -381,7 +381,7 @@
}
.global-bottom-bar.is-expanded .bb-sheet-panel {
max-height: min(52vh, 460px);
max-height: min(var(--bottom-bar-expanded-height), 590px);
opacity: 1;
}
@ -390,10 +390,10 @@
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 16px;
display: flex;
flex-direction: column;
min-height: 240px;
max-height: min(52vh, 420px);
height: min(52vh, 420px);
flex-direction: row;
min-height: 320px;
max-height: min(var(--bottom-bar-expanded-height), 540px);
height: min(var(--bottom-bar-expanded-height), 540px);
overflow: hidden;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
margin-top: 0.5rem;
@ -403,15 +403,18 @@
}
.global-bottom-bar .bb-side-tabs {
border-right: 0;
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.09);
background: rgba(var(--text-primary-rgb), 0.025);
padding: 0.55rem 0.65rem;
width: 176px;
flex: 0 0 176px;
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
border-bottom: 0;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
padding: 0.85rem 0.65rem;
display: flex;
gap: 0.35rem;
align-items: center;
min-height: auto;
overflow-x: auto;
flex-direction: column;
gap: 0.3rem;
align-items: stretch;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
.global-bottom-bar .bb-side-tabs::-webkit-scrollbar { display: none; }
@ -421,10 +424,10 @@
background: transparent;
color: var(--text-secondary);
border-radius: 9px;
text-align: center;
font-size: 0.8rem;
text-align: left;
font-size: 0.84rem;
font-weight: 600;
padding: 0.45rem 0.7rem;
padding: 0.62rem 0.7rem;
line-height: 1.3;
transition: all 0.2s ease;
display: flex;
@ -432,6 +435,7 @@
gap: 0.5rem;
white-space: nowrap;
flex: 0 0 auto;
width: 100%;
}
.global-bottom-bar .bb-tab-btn i {
font-size: 1rem;
@ -475,48 +479,58 @@
}
.global-bottom-bar .bb-tab-content {
padding: 0.85rem 1rem 1rem;
padding: 1rem 1.15rem 1.1rem;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1 1 0;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
}
.global-bottom-bar .bb-tab-title {
font-size: 1rem;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
margin-bottom: 0.15rem;
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.15;
}
.global-bottom-bar .bb-tab-heading { flex: 0 0 auto; padding-bottom: 0.75rem; margin-bottom: 0.75rem; border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08); }
.global-bottom-bar .bb-tab-description { color: var(--text-secondary); font-size: 0.78rem; line-height: 1.35; }
.global-bottom-bar .bb-tab-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
gap: 0.45rem;
}
.global-bottom-bar .bb-tab-list li {
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
padding: 0.65rem 0.85rem;
border: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: color-mix(in srgb, var(--bg-card) 96%, var(--text-primary));
border-radius: 11px;
padding: 0.68rem 0.78rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
box-shadow: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
}
.global-bottom-bar .bb-tab-content .bb-tab-list { overflow-y: auto; min-height: 0; padding-right: 0.25rem; }
.global-bottom-bar .bb-task-actions { display: flex; justify-content: flex-end; margin: 0 0 0.75rem !important; width: 100%; }
.global-bottom-bar .bb-task-actions .btn { width: auto !important; box-shadow: none !important; }
.global-bottom-bar .bb-boss-hero {
display: flex;
align-items: center;
@ -535,7 +549,7 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
gap: 0.6rem;
padding: 0.65rem 0.7rem;
border: 1px solid rgba(245, 158, 11, 0.28);
border-left: 4px solid #f59e0b;
@ -558,7 +572,7 @@
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.1rem;
padding: 0.1rem 0 0.45rem;
scrollbar-width: none;
}
.global-bottom-bar .bb-message-threads::-webkit-scrollbar {
@ -600,7 +614,8 @@
color: #fff;
}
.global-bottom-bar .bb-messages-list {
max-height: min(26vh, 240px);
max-height: none;
flex: 1 1 auto;
overflow-y: auto;
padding-right: 0.2rem;
}
@ -608,10 +623,16 @@
flex: 1 1 auto;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.global-bottom-bar #bbTabInnerContent > * { width: 100%; min-width: 0; }
.global-bottom-bar .bb-panel-overview,
.global-bottom-bar .bb-panel-tasks,
.global-bottom-bar .bb-panel-boss { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); align-content: start; }
.global-bottom-bar .bb-panel-timer { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-content: start; }
.global-bottom-bar .bb-notes-layout {
display: flex;
flex-direction: column;
@ -621,23 +642,44 @@
overflow: hidden;
}
.global-bottom-bar .bb-notes-editor {
flex: 0 0 auto;
min-width: 0;
}
.global-bottom-bar .bb-notes-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.global-bottom-bar .bb-messages-composer {
border-top: 1px solid rgba(var(--text-primary-rgb), 0.08);
padding-top: 0.55rem;
border: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
border-radius: 12px;
padding: 0.65rem;
margin-top: 0.15rem;
}
.global-bottom-bar .bb-messages-layout {
height: 100%;
overflow: hidden;
}
.global-bottom-bar .bb-messages-list li { border: 0; background: transparent; padding: 0.2rem 0.1rem; }
.global-bottom-bar .bb-messages-list li:hover { transform: none; box-shadow: none; }
.global-bottom-bar .bb-notes-editor > li { border: 0; background: transparent; padding: 0; }
.global-bottom-bar .bb-notes-editor > li > .border { border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent) !important; border-radius: 12px !important; background: color-mix(in srgb, var(--accent) 4%, var(--bg-card)) !important; padding: 0.8rem !important; margin: 0 !important; }
.global-bottom-bar .bb-boss-kpis { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); }
.global-bottom-bar .bb-boss-kpis > [class*="col-"] { width: auto; }
.global-bottom-bar .bb-panel-timer li { border-color: rgba(25, 135, 84, 0.18); background: rgba(25, 135, 84, 0.045); }
.global-bottom-bar .bb-timer-case-link { cursor: pointer; }
.global-bottom-bar .bb-timer-case-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
.global-bottom-bar .bb-timer-work-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.3rem; overflow-y: auto; min-height: 0; }
.global-bottom-bar .bb-timer-work-card { display: flex; align-items: center; justify-content: space-between; gap: 0.55rem; min-height: 38px; padding: 0.28rem 0.45rem; border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 8px; background: var(--bg-card); cursor: pointer; font-size: 0.72rem; }
.global-bottom-bar .bb-timer-work-main { display: flex; align-items: center; gap: 0.45rem; min-width: 0; flex: 1 1 auto; white-space: nowrap; overflow: hidden; }
.global-bottom-bar .bb-timer-work-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.76rem; }
.global-bottom-bar .bb-timer-work-meta { color: var(--text-secondary); flex: 0 0 auto; font-size: 0.68rem; white-space: nowrap; }
.global-bottom-bar .bb-timer-work-actions { display: flex; gap: 0.25rem; flex: 0 0 auto; }
.global-bottom-bar .bb-timer-work-card .btn { --bs-btn-padding-y: 0.18rem; --bs-btn-padding-x: 0.4rem; --bs-btn-font-size: 0.68rem; }
.global-bottom-bar .bb-panel-tasks li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.global-bottom-bar .bb-panel-empty { min-height: 140px; display: grid; place-items: center; text-align: center; color: var(--text-secondary); }
.global-bottom-bar .bb-panel-empty i { display: block; font-size: 1.8rem; color: color-mix(in srgb, var(--accent) 55%, var(--text-secondary)); margin-bottom: 0.35rem; }
.global-bottom-bar .bb-live-indicator { width: 0.58rem; height: 0.58rem; border-radius: 50%; background: #198754; box-shadow: 0 0 0 5px rgba(25, 135, 84, 0.12); display: inline-block; margin-right: 0.55rem; }
.global-bottom-bar .bb-detail-line {
min-height: 28px;
padding: 0.2rem 0.2rem 0;
@ -659,6 +701,34 @@
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08);
}
#bbSwitchCaseModal .modal-dialog { max-width: 1080px; }
#bbSwitchCaseModal .modal-body { padding: 1rem 1.15rem 1.2rem; }
#bbSwitchCaseModal .bb-switch-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.8rem; margin-bottom: 1rem; border: 1px solid rgba(var(--text-primary-rgb), 0.08); border-radius: 12px; background: color-mix(in srgb, var(--accent) 4%, var(--bg-card)); }
#bbSwitchCaseModal .bb-switch-search { max-width: 360px; }
#bbSwitchCaseModal .bb-switch-section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); }
#bbSwitchCaseModal .bb-switch-timers { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; margin-bottom: 1rem; }
#bbSwitchCaseModal .bb-switch-timer, #bbSwitchCaseModal .bb-switch-case { border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 11px; background: var(--bg-card); }
#bbSwitchCaseModal .bb-switch-timer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.7rem; }
#bbSwitchCaseModal .bb-switch-timer-info { display: flex; align-items: center; gap: 0.55rem; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-info-title { min-width: 120px; max-width: 34%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-info-meta { color: var(--text-secondary); font-size: 0.72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-status { font-size: 0.66rem; font-weight: 600; }
#bbSwitchCaseModal .bb-switch-timer-pending { width: 100%; min-height: 38px; padding: 0.28rem 0.4rem; gap: 0.35rem; border-left: 2px solid var(--bs-info); font-size: 0.72rem; }
#bbSwitchCaseModal .bb-switch-timer-main { display: flex; align-items: center; gap: 0.35rem; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-timer-title { color: var(--text-primary); font-size: 0.72rem; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-timer-meta { flex: 0 0 auto; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-timer-pending .bb-switch-case-actions { gap: 0.25rem; }
#bbSwitchCaseModal .bb-switch-timer-pending .btn { --bs-btn-padding-y: 0.18rem; --bs-btn-padding-x: 0.38rem; --bs-btn-font-size: 0.68rem; }
#bbSwitchCaseModal .bb-switch-cases { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
#bbSwitchCaseModal .bb-switch-case { display: flex; align-items: center; gap: 0.75rem; padding: 0.72rem; min-width: 0; }
#bbSwitchCaseModal .bb-switch-case-main { flex: 1 1 auto; min-width: 0; }
#bbSwitchCaseModal .bb-switch-case-title { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-case-meta { color: var(--text-secondary); font-size: 0.75rem; margin-top: 0.15rem; }
#bbSwitchCaseModal .bb-switch-case-actions { display: flex; gap: 0.4rem; flex: 0 0 auto; }
#bbSwitchCaseModal .bb-switch-case-actions .btn { white-space: nowrap; }
#bbSwitchCaseModal .bb-switch-empty { grid-column: 1 / -1; padding: 1.4rem; text-align: center; color: var(--text-secondary); border: 1px dashed rgba(var(--text-primary-rgb), 0.14); border-radius: 11px; }
#bbSwitchCaseModal .bb-convert-panel { padding: 0.9rem; border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--accent) 6%, var(--bg-card)); }
#bbSwitchCaseModal .list-group-item {
border-color: rgba(var(--text-primary-rgb), 0.08);
transition: background-color 0.2s ease;
@ -690,6 +760,7 @@
.global-bottom-bar .bb-sheet-inner {
min-height: 240px;
flex-direction: column;
}
.global-bottom-bar.is-expanded .bb-header {
@ -705,13 +776,22 @@
}
.global-bottom-bar .bb-side-tabs {
width: 100%;
flex: 0 0 auto;
flex-direction: row;
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
overflow-x: auto;
}
.global-bottom-bar .bb-tab-btn { width: auto; }
.global-bottom-bar .bb-notes-layout { grid-template-columns: 1fr; overflow-y: auto; }
.global-bottom-bar .bb-boss-kpis { grid-template-columns: repeat(2, 1fr); }
.global-bottom-bar .bb-support-queue-card { align-items: stretch; flex-direction: column; }
.global-bottom-bar .bb-support-queue-actions { width: 100%; }
.global-bottom-bar .bb-support-queue-actions select { width: auto; flex: 1 1 auto; }
#bbSwitchCaseModal .bb-switch-toolbar { align-items: stretch; flex-direction: column; }
#bbSwitchCaseModal .bb-switch-search { max-width: none; }
#bbSwitchCaseModal .bb-switch-cases { grid-template-columns: 1fr; }
}
.navbar {
@ -1374,7 +1454,10 @@
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-diagram-3"></i> Sagsfordeling</button>
</div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div class="bb-tab-heading">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
<div id="bbTabDescription" class="bb-tab-description">Det vigtigste samlet ét sted.</div>
</div>
<div id="bbTabInnerContent">
<ul id="bbTabList" class="bb-tab-list">
<li>Venter på data...</li>
@ -1386,14 +1469,17 @@
</div>
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel">
<div class="modal-dialog modal-dialog-scrollable modal-lg modal-dialog-bottom">
<div class="modal-dialog modal-dialog-scrollable modal-xl modal-dialog-bottom">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbSwitchCaseStatus" class="small text-muted mb-3">Henter data...</div>
<div class="bb-switch-toolbar">
<div id="bbSwitchCaseStatus" class="small text-muted">Henter data...</div>
<div class="input-group input-group-sm bb-switch-search"><span class="input-group-text"><i class="bi bi-search"></i></span><input id="bbSwitchCaseSearch" type="search" class="form-control" placeholder="Søg efter sag eller nummer" aria-label="Søg efter sag"></div>
</div>
<div id="bbSwitchTimerActions" class="mb-3 d-none">
<div class="fw-semibold mb-2">Aktiv timer fundet</div>
@ -1404,20 +1490,23 @@
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<h6 class="mb-2">Dine aktive/pausede timere</h6>
<div id="bbSwitchTimersList" class="list-group small">
<div class="list-group-item text-muted">Henter timere...</div>
</div>
</div>
<div class="col-12 col-lg-6">
<h6 class="mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="list-group small">
<div class="list-group-item text-muted">Henter sager...</div>
</div>
<div id="bbConvertTimePanel" class="bb-convert-panel d-none mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><div class="fw-bold">Konverter afsluttet tid</div><div id="bbConvertTimeName" class="small text-muted"></div></div>
<button type="button" class="btn-close" data-bb-cancel-convert aria-label="Luk"></button>
</div>
<div class="row g-2">
<div class="col-6 col-lg-3"><label class="form-label small">Arbejdstype</label><select id="bbConvertWorkType" class="form-select form-select-sm"><option value="support">Support</option><option value="troubleshooting">Fejlsøgning</option><option value="development">Udvikling</option><option value="maintenance">Vedligehold</option><option value="on_site">Kørsel / On-site</option><option value="meeting">Møde</option><option value="other">Andet</option></select></div>
<div class="col-6 col-lg-3"><label class="form-label small">Fakturerbare minutter</label><input id="bbConvertMinutes" type="number" min="0" step="1" class="form-control form-control-sm"></div>
<div class="col-12 col-lg-4"><label class="form-label small">Afregning</label><select id="bbConvertBillingMethod" class="form-select form-select-sm"></select><div id="bbConvertRecommendation" class="form-text"></div></div>
<div class="col-12 col-lg-2 d-flex align-items-end"><button id="bbConvertSubmit" type="button" class="btn btn-primary btn-sm w-100"><i class="bi bi-check2-circle me-1"></i>Konverter tid</button></div>
</div>
</div>
<h6 class="bb-switch-section-title mb-2">Timere på dine sager</h6>
<div id="bbSwitchTimersList" class="bb-switch-timers small"><div class="bb-switch-empty">Henter timere...</div></div>
<h6 class="bb-switch-section-title mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="bb-switch-cases small"><div class="bb-switch-empty">Henter sager...</div></div>
</div>
</div>
</div>
@ -1489,7 +1578,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.44"></script>
<script src="/static/js/bottom-bar.js?v=2.60"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true;
@ -2079,6 +2168,9 @@ if (bmcOriginalFetch) {
const buzzwordHtml = buzzwords.length
? ` • <i class="bi bi-lightbulb ms-1"></i> ${buzzwords.map(word => escapeHtml(word)).join(', ')}`
: '';
const archivedHtml = item.is_archived
? ' • <span class="badge bg-secondary">Arkiveret</span>'
: '';
return `
<div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;">
<div>
@ -2087,6 +2179,7 @@ if (bmcOriginalFetch) {
<i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')}
${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
${buzzwordHtml}
${archivedHtml}
</div>
</div>
<i class="bi bi-arrow-right"></i>

View File

@ -73,7 +73,7 @@
<tbody id="subscriptionsBody">
<tr>
<td colspan="9" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
</td>
</tr>
</tbody>
@ -539,7 +539,7 @@ async function loadSubscriptions() {
document.getElementById('subscriptionsBody').innerHTML = `
<tr><td colspan="9" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
@ -820,9 +820,9 @@ function formatInterval(interval) {
const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Maaned',
'monthly': 'Måned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
'yearly': 'År'
};
return map[interval] || interval || '-';
}

View File

@ -48,6 +48,7 @@ class WorkType(str, Enum):
"""Type af arbejde"""
SUPPORT = "support"
DEVELOPMENT = "development"
MAINTENANCE = "maintenance"
TROUBLESHOOTING = "troubleshooting"
ON_SITE = "on_site"
MEETING = "meeting"

View File

@ -626,7 +626,7 @@
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 },
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
];
@ -725,6 +725,7 @@
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="maintenance">Vedligehold</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>

View File

@ -372,6 +372,7 @@
<option value="support">Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="maintenance">Vedligehold</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>

View File

@ -116,7 +116,7 @@ class OrderService:
WHERE t.customer_id = %s
AND t.status = 'approved'
AND t.billable = true
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card')
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card', 'subscription', 'fixed_price', 'internal', 'non_billable', 'warranty')
ORDER BY COALESCE(c.id, s.id), t.worked_date
"""
approved_times = execute_query(query, (customer_id,))

View File

@ -162,6 +162,48 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
)
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
"""Return valid settlement choices and a deterministic recommendation."""
customer = execute_query_single(
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
(customer_id,),
) or {}
hub_customer_id = customer.get("hub_customer_id")
cards = []
agreements = []
if hub_customer_id:
cards = execute_query(
"""
SELECT id, card_number, remaining_hours, rounding_minutes, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active' AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
ORDER BY expires_at ASC NULLS LAST, created_at ASC
""",
(hub_customer_id,),
) or []
agreements = execute_query(
"""
SELECT id, agreement_number, monthly_hours
FROM customer_fixed_price_agreements
WHERE customer_id = %s AND status = 'active'
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
ORDER BY created_at ASC
""",
(hub_customer_id,),
) or []
if cards:
recommendation = {"method": "prepaid", "prepaid_card_id": cards[0]["id"], "reason": "Kunden har et aktivt klippekort"}
elif agreements:
recommendation = {"method": "subscription", "fixed_price_agreement_id": agreements[0]["id"], "reason": "Kunden har en aktiv aftale"}
else:
recommendation = {"method": "invoice", "reason": "Der er ingen aktiv inkluderet aftale"}
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try:
@ -1811,7 +1853,10 @@ async def list_time_entries(
status: Optional[str] = None,
customer_id: Optional[int] = None,
user_name: Optional[str] = None,
search: Optional[str] = None
search: Optional[str] = None,
work_type: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
):
"""
Hent liste af tidsregistreringer med filtre.
@ -1841,6 +1886,18 @@ async def list_time_entries(
query += " AND t.user_name ILIKE %s"
params.append(f"%{user_name}%")
if work_type:
query += " AND COALESCE(t.work_type, 'support') = %s"
params.append(work_type)
if start_date:
query += " AND DATE(COALESCE(t.worked_date, t.created_at)) >= %s"
params.append(start_date)
if end_date:
query += " AND DATE(COALESCE(t.worked_date, t.created_at)) <= %s"
params.append(end_date)
if search:
query += """ AND (
t.description ILIKE %s OR
@ -1850,11 +1907,36 @@ async def list_time_entries(
wildcard = f"%{search}%"
params.extend([wildcard, wildcard, wildcard])
filtered_query = query
filtered_params = list(params)
query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
params.extend([min(max(limit, 1), 10000), max(offset, 0)])
times = execute_query(query, tuple(params))
return {"times": times}
summary = execute_query_single(
f"""
SELECT
COUNT(*)::int AS total_entries,
COALESCE(SUM(t.original_hours), 0)::numeric AS total_hours,
COALESCE(SUM(CASE WHEN t.billable THEN COALESCE(t.approved_hours, t.original_hours, 0) ELSE 0 END), 0)::numeric AS billable_hours,
COUNT(DISTINCT COALESCE(t.medarbejder_id::text, t.user_name))::int AS total_employees,
COUNT(DISTINCT COALESCE(t.sag_id, t.case_id))::int AS total_cases
FROM ({filtered_query}) AS t
""",
tuple(filtered_params),
) or {}
type_rows = execute_query(
f"""
SELECT COALESCE(t.work_type, 'support') AS work_type,
COUNT(*)::int AS entries,
COALESCE(SUM(t.original_hours), 0)::numeric AS hours
FROM ({filtered_query}) AS t
GROUP BY COALESCE(t.work_type, 'support')
ORDER BY hours DESC
""",
tuple(filtered_params),
) or []
return {"times": times, "summary": summary, "by_work_type": type_rows}
except Exception as e:
logger.error(f"Error listing times: {e}")
raise HTTPException(status_code=500, detail=str(e))
@ -2239,6 +2321,7 @@ async def list_time_entries_v1(
sag_id: int = Query(..., gt=0),
day: Optional[date] = Query(None),
medarbejder_id: Optional[int] = Query(None, gt=0),
current_user: Optional[dict] = Depends(get_optional_user),
):
"""List tidsregistreringer for en sag med filtre til timeline UI."""
try:
@ -2255,13 +2338,31 @@ async def list_time_entries_v1(
where_sql = " AND ".join(clauses)
query = f"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
cust.name AS customer_name, primary_contact.contact_name,
u.full_name AS employee_display_name, u.username AS employee_username,
(t.medarbejder_id = %s) AS is_own_timer,
CASE
WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL THEN
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0), 0)
ELSE NULL
END AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers cust ON cust.id = s.customer_id
LEFT JOIN LATERAL (
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
FROM sag_kontakter sk
JOIN contacts cont ON cont.id = sk.contact_id
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE {where_sql}
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC
"""
return execute_query(query, tuple(params))
return execute_query(query, tuple([_resolve_current_user_id(current_user)] + params))
except Exception as e:
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to list time entries")
@ -2303,35 +2404,14 @@ async def start_live_timer_v1(
paused_entry = None
if existing:
actual_minutes = _elapsed_minutes_excluding_pause(existing, now)
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
pause_total_seconds = _pause_total_seconds_at(existing, now)
execute_update(
"""
UPDATE tmodule_times
SET slut_tid = %s,
aktiv_timer = FALSE,
paused_at = NULL,
pause_total_seconds = %s,
faktisk_tid_min = %s,
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
entry_status = 'afventer',
status = 'pending'
SET aktiv_timer = FALSE,
paused_at = %s
WHERE id = %s
""",
(
now,
pause_total_seconds,
actual_minutes,
rounded_minutes,
actual_minutes,
rounded_minutes,
existing.get("round_block_min") or 30,
existing["id"],
)
(now, existing["id"])
)
paused_entry = existing["id"]
@ -2353,13 +2433,13 @@ async def start_live_timer_v1(
worked_date, user_name, status, billable,
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret
aktiv_timer, round_block_min, ikke_placeret, work_type
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s
%s, %s, %s, %s
) RETURNING *
""",
(
@ -2382,6 +2462,7 @@ async def start_live_timer_v1(
True,
round_block_min,
False,
payload.get("work_type") or "support",
)
)
@ -2615,7 +2696,7 @@ async def resume_live_timer_v1(
async def list_my_switchable_timers_v1(
current_user: Optional[dict] = Depends(get_optional_user)
):
"""List authenticated user's currently active and paused timers for switch-case UI."""
"""List authenticated user's active, paused and recently stopped timers for switch-case UI."""
try:
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
@ -2623,8 +2704,24 @@ async def list_my_switchable_timers_v1(
active = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
cust.name AS customer_name, primary_contact.contact_name,
u.full_name AS employee_display_name, u.username AS employee_username,
GREATEST(
EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0),
0
) AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers cust ON cust.id = s.customer_id
LEFT JOIN LATERAL (
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
FROM sag_kontakter sk
JOIN contacts cont ON cont.id = sk.contact_id
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
@ -2637,8 +2734,13 @@ async def list_my_switchable_timers_v1(
paused = execute_query(
"""
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
SELECT t.*, s.titel AS sag_navn, u.full_name AS employee_display_name, u.username AS employee_username,
GREATEST(
EXTRACT(EPOCH FROM (t.paused_at - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0),
0
) AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL
@ -2649,9 +2751,38 @@ async def list_my_switchable_timers_v1(
(bruger_id,),
)
stopped = execute_query(
"""
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
cust.name AS customer_name, primary_contact.contact_name,
u.full_name AS employee_display_name, u.username AS employee_username
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers cust ON cust.id = s.customer_id
LEFT JOIN LATERAL (
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
FROM sag_kontakter sk
JOIN contacts cont ON cont.id = sk.contact_id
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s
AND t.slut_tid IS NOT NULL
AND t.aktiv_timer = FALSE
AND COALESCE(t.entry_status, 'afventer') <> 'godkendt'
AND COALESCE(t.status, 'pending') = 'pending'
ORDER BY t.slut_tid DESC NULLS LAST, t.id DESC
LIMIT 25
""",
(bruger_id,),
)
return {
"active": active or [],
"paused": paused or [],
"stopped": stopped or [],
}
except HTTPException:
raise
@ -2660,6 +2791,47 @@ async def list_my_switchable_timers_v1(
raise HTTPException(status_code=500, detail="Failed to list switchable timers")
@router.get("/time/team-status", tags=["Internal"])
async def list_team_timer_status_v1(
scope: str = Query("mine", pattern="^(mine|all)$"),
current_user: Optional[dict] = Depends(get_optional_user),
):
"""Timer work queue for the bottom bar, optionally across all employees."""
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
employee_filter = "AND t.medarbejder_id = %s" if scope == "mine" else ""
params = (bruger_id,) if scope == "mine" else ()
rows = execute_query(
f"""
SELECT t.*, s.titel AS sag_navn,
COALESCE(u.full_name, u.username, t.user_name, 'Ukendt medarbejder') AS employee_display_name,
(t.medarbejder_id = %s) AS is_own_timer,
CASE
WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL THEN 'active'
WHEN t.paused_at IS NOT NULL AND t.slut_tid IS NULL THEN 'paused'
ELSE 'pending_conversion'
END AS timer_state,
CASE WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL
THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0), 0)
ELSE NULL END AS live_elapsed_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE (
(t.slut_tid IS NULL AND (t.aktiv_timer = TRUE OR t.paused_at IS NOT NULL))
OR (t.slut_tid IS NOT NULL AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending')
)
{employee_filter}
ORDER BY CASE WHEN t.aktiv_timer THEN 0 WHEN t.slut_tid IS NULL THEN 1 ELSE 2 END,
COALESCE(t.updated_at, t.created_at) DESC
LIMIT 100
""",
tuple([bruger_id] + list(params)),
) or []
return {"scope": scope, "rows": rows}
@router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...),
@ -2723,7 +2895,7 @@ async def create_manual_time_v1(
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret,
approved_hours, rounded_to
approved_hours, rounded_to, work_type
) VALUES (
%s, %s, %s, %s,
%s, %s, %s,
@ -2731,7 +2903,7 @@ async def create_manual_time_v1(
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s,
%s, %s
%s, %s, %s
) RETURNING *
"""
@ -2761,6 +2933,7 @@ async def create_manual_time_v1(
not_placed,
(billable_minutes / 60.0) if billable else None,
(round_block_min / 60.0) if billable else None,
payload.get("work_type") or "support",
)
)
return inserted[0] if inserted else None
@ -2787,7 +2960,7 @@ async def patch_time_entry_v1(
updates: Dict[str, Any] = {}
allowed_direct = [
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id"
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id", "work_type"
]
for key in allowed_direct:
if key in payload:
@ -2850,6 +3023,15 @@ async def patch_time_entry_v1(
raise HTTPException(status_code=500, detail="Failed to patch time entry")
@router.get("/time/{time_id}/settlement-options", tags=["Internal"])
async def get_time_settlement_options_v1(time_id: int):
"""Show valid invoice, prepaid and agreement choices before approval."""
entry = execute_query_single("SELECT id, customer_id FROM tmodule_times WHERE id = %s", (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
return _settlement_options_for_customer(entry.get("customer_id"))
@router.post("/time/{time_id}/approve", tags=["Internal"])
async def approve_time_entry_v1(
time_id: int,
@ -2865,13 +3047,26 @@ async def approve_time_entry_v1(
_assert_prepaid_entry_editable(entry)
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
work_type = str(payload.get("work_type") or entry.get("work_type") or "support")
if work_type not in {"support", "troubleshooting", "development", "maintenance", "on_site", "meeting", "other"}:
raise HTTPException(status_code=400, detail="Invalid work_type")
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if entry_type == "ukendt":
if not is_admin_approver:
raise HTTPException(status_code=400, detail="entry_type is required before approval")
logger.warning("⚠️ Admin approved time entry with ukendt type (time_id=%s)", time_id)
billable = bool(payload.get("fakturerbar", entry.get("billable", True)))
options = _settlement_options_for_customer(entry.get("customer_id"))
recommendation = options.get("recommended") or {"method": "invoice"}
billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower()
if billing_method == "fixed_price":
billing_method = "subscription"
if billing_method not in {"invoice", "prepaid", "subscription", "internal", "non_billable"}:
raise HTTPException(status_code=400, detail="Invalid billing_method")
prepaid_card_id = payload.get("prepaid_card_id") or recommendation.get("prepaid_card_id")
agreement_id = payload.get("fixed_price_agreement_id") or recommendation.get("fixed_price_agreement_id")
billable = billing_method in {"invoice", "prepaid"}
billed_minutes = payload.get("fakturerbar_tid_min")
if billed_minutes is None:
billed_minutes = entry.get("fakturerbar_tid_min")
@ -2880,16 +3075,48 @@ async def approve_time_entry_v1(
billed_minutes = _round_up_minutes(faktisk, int(entry.get("round_block_min") or 30))
billed_minutes = int(billed_minutes)
final_status = "approved" if billing_method == "invoice" else "billed"
if billing_method == "prepaid":
valid_card_ids = {int(card["id"]) for card in options.get("prepaid_cards", [])}
if not prepaid_card_id or int(prepaid_card_id) not in valid_card_ids:
raise HTTPException(status_code=409, detail="Vælg et aktivt klippekort for kunden")
card = next(card for card in options["prepaid_cards"] if int(card["id"]) == int(prepaid_card_id))
rounding = max(1, int(card.get("rounding_minutes") or entry.get("round_block_min") or 30))
billed_minutes = _round_up_minutes(billed_minutes, rounding)
hours = billed_minutes / 60.0
debited = execute_query(
"""
UPDATE tticket_prepaid_cards
SET used_hours = used_hours + %s
WHERE id = %s AND status = 'active' AND remaining_hours >= %s
RETURNING id, remaining_hours
""",
(hours, prepaid_card_id, hours),
)
if not debited:
raise HTTPException(status_code=409, detail="Klippekortet har ikke timer nok")
elif billing_method == "subscription":
valid_agreement_ids = {int(agreement["id"]) for agreement in options.get("agreements", [])}
if not agreement_id or int(agreement_id) not in valid_agreement_ids:
raise HTTPException(status_code=409, detail="Vælg en aktiv aftale for kunden")
billable = False
elif billing_method in {"internal", "non_billable"}:
billable = False
approved_by = _resolve_current_user_id(current_user)
updated = execute_query(
"""
UPDATE tmodule_times
SET entry_type = %s,
work_type = %s,
entry_status = 'godkendt',
status = 'approved',
status = %s,
billable = %s,
billing_method = %s,
prepaid_card_id = %s,
fixed_price_agreement_id = %s,
fakturerbar_tid_min = CASE WHEN %s THEN %s ELSE 0 END,
approved_hours = CASE WHEN %s THEN (%s::numeric / 60.0) ELSE 0 END,
approved_hours = CASE WHEN %s AND %s > 0 THEN (%s::numeric / 60.0) ELSE NULL END,
approved_at = %s,
approved_by = %s,
aktiv_timer = FALSE
@ -2898,11 +3125,17 @@ async def approve_time_entry_v1(
""",
(
entry_type,
work_type,
final_status,
billable,
billing_method,
int(prepaid_card_id) if billing_method == "prepaid" else None,
int(agreement_id) if billing_method == "subscription" else None,
billable,
billed_minutes,
billable,
billed_minutes,
billed_minutes,
datetime.now(),
approved_by,
time_id,

View File

@ -114,7 +114,7 @@
<div class="container-fluid py-4">
<div class="log-hero">
<h1 class="h4 mb-1">Medarbejder Log</h1>
<div>Visualiser registreret tid over dag, uge og maaned, og find manglende tider/sager.</div>
<div>Visualiser registreret tid over dag, uge og måned, og find manglende tider/sager.</div>
</div>
<div class="log-card">
@ -124,7 +124,7 @@
<select id="granularity" class="form-select">
<option value="day">Dag</option>
<option value="week" selected>Uge</option>
<option value="month">Maaned</option>
<option value="month">Måned</option>
</select>
</div>
<div>
@ -136,7 +136,7 @@
<input id="endDate" type="date" class="form-control">
</div>
<div>
<label class="form-label small">Maal timer/dag</label>
<label class="form-label small">Mål: timer/dag</label>
<input id="targetHours" type="number" min="0" max="24" step="0.5" value="7.5" class="form-control">
</div>
<div>

View File

@ -57,6 +57,25 @@
.status-rejected { background: #f8d7da; color: #842029; }
.status-billed { background: #cfe2ff; color: #084298; }
.report-summary {
display: grid;
grid-template-columns: repeat(5, minmax(130px, 1fr));
gap: 0.75rem;
}
.report-summary-card {
padding: 0.9rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.report-summary-card .value { font-size: 1.35rem; font-weight: 700; }
@media (max-width: 992px) {
.report-summary { grid-template-columns: repeat(2, 1fr); }
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
{% endblock %}
@ -69,9 +88,10 @@
<h1 class="mb-1">Tidsregistreringer</h1>
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
</div>
<button class="btn btn-outline-primary" onclick="loadData()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
<div class="d-flex gap-2">
<button class="btn btn-outline-success" onclick="exportReportCsv()"><i class="bi bi-file-earmark-spreadsheet"></i> Eksportér CSV</button>
<button class="btn btn-outline-primary" onclick="loadData()"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
</div>
</div>
<!-- Filters -->
@ -98,8 +118,31 @@
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label>
<input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Arbejdstype</label>
<select id="filter-work-type" class="form-select" onchange="loadData()">
<option value="">Alle typer</option>
<option value="support">Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="maintenance">Vedligehold</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Fra dato</label>
<input type="date" id="filter-start-date" class="form-control" onchange="loadData()">
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Til dato</label>
<input type="date" id="filter-end-date" class="form-control" onchange="loadData()">
</div>
</div>
</div>
<div id="report-summary" class="report-summary mb-4"></div>
<!-- Table -->
<div class="registrations-table">
@ -111,6 +154,7 @@
<th style="width: 20%;">Kunde</th>
<th style="width: 25%;">Beskrivelse / Case</th>
<th>Tekniker</th>
<th>Type</th>
<th class="text-center">Timer</th>
<th class="text-center">Fakt.</th>
<th>Status</th>
@ -118,7 +162,7 @@
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="8" class="text-center py-5 text-muted">Henter data...</td></tr>
<tr><td colspan="9" class="text-center py-5 text-muted">Henter data...</td></tr>
</tbody>
</table>
</div>
@ -158,28 +202,36 @@
debounceTimer = setTimeout(loadData, 500);
}
const workTypeLabels = {
support: 'Support', troubleshooting: 'Fejlsøgning', development: 'Udvikling',
maintenance: 'Vedligehold', on_site: 'Kørsel / On-site', meeting: 'Møde', other: 'Andet'
};
function buildReportParams(limit = 100) {
const params = new URLSearchParams({ limit, offset: 0 });
const values = {
search: document.getElementById('filter-search').value,
status: document.getElementById('filter-status').value,
user_name: document.getElementById('filter-user').value,
work_type: document.getElementById('filter-work-type').value,
start_date: document.getElementById('filter-start-date').value,
end_date: document.getElementById('filter-end-date').value
};
Object.entries(values).forEach(([key, value]) => { if (value) params.append(key, value); });
return params;
}
async function loadData() {
const tbody = document.getElementById('table-body');
const search = document.getElementById('filter-search').value;
const status = document.getElementById('filter-status').value;
const user = document.getElementById('filter-user').value;
// Build URL
const params = new URLSearchParams({
limit: 100, // Hardcoded limit for now
offset: 0
});
if (search) params.append('search', search);
if (status) params.append('status', status);
if (user) params.append('user_name', user);
const params = buildReportParams();
try {
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
const data = await response.json();
renderReportSummary(data);
if (data.times.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5">Ingen resultater fundet</td></tr>`;
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5">Ingen resultater fundet</td></tr>`;
return;
}
@ -203,6 +255,7 @@
<td>
${t.user_name || '-'}
</td>
<td><span class="badge bg-secondary">${workTypeLabels[t.work_type || 'support'] || t.work_type || 'Support'}</span></td>
<td class="text-center">
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
</td>
@ -225,10 +278,46 @@
} catch (error) {
console.error(error);
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
}
}
function renderReportSummary(data) {
const summary = data.summary || {};
const cards = [
['Timer', Number(summary.total_hours || 0).toFixed(2)],
['Fakturerbare timer', Number(summary.billable_hours || 0).toFixed(2)],
['Registreringer', summary.total_entries || 0],
['Medarbejdere', summary.total_employees || 0],
['Sager', summary.total_cases || 0]
];
const typeText = (data.by_work_type || [])
.map(row => `${workTypeLabels[row.work_type] || row.work_type}: ${Number(row.hours || 0).toFixed(2)} t`)
.join(' · ');
document.getElementById('report-summary').innerHTML = cards.map(([label, value]) => `
<div class="report-summary-card"><div class="small text-muted">${label}</div><div class="value">${value}</div></div>
`).join('') + (typeText ? `<div class="small text-muted grid-column-span-all" style="grid-column:1/-1">Fordeling: ${typeText}</div>` : '');
}
async function exportReportCsv() {
const response = await fetch(`/api/v1/timetracking/times?${buildReportParams(10000).toString()}`);
const data = await response.json();
if (!response.ok) return alert(data.detail || 'Kunne ikke eksportere rapporten');
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`;
const rows = [['Dato', 'Kunde', 'Sag', 'Medarbejder', 'Type', 'Beskrivelse', 'Timer', 'Fakturerbar', 'Status']];
(data.times || []).forEach(t => rows.push([
t.worked_date, t.customer_name, t.case_title, t.user_name,
workTypeLabels[t.work_type || 'support'] || t.work_type,
t.description, t.original_hours, t.billable ? 'Ja' : 'Nej', t.status
]));
const csv = '\ufeff' + rows.map(row => row.map(quote).join(';')).join('\n');
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
link.download = `tidsrapport-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('da-DK');

View File

@ -126,7 +126,7 @@
<div class="container-fluid py-4">
<div class="report-hero">
<h1>Servicekontrakt Rapport</h1>
<p>Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.</p>
<p>Vælg kunde og servicekontrakt for at se relaterede sager og tidsregistreringer fra vTiger.</p>
</div>
<div class="report-card mb-3">
@ -134,13 +134,13 @@
<div>
<label for="customerSelect" class="form-label fw-semibold">Kunde</label>
<select id="customerSelect" class="form-select">
<option value="">-- Vaelg kunde --</option>
<option value="">-- Vælg kunde --</option>
</select>
</div>
<div>
<label for="contractSelect" class="form-label fw-semibold">Servicekontrakt</label>
<select id="contractSelect" class="form-select" disabled>
<option value="">-- Vaelg servicekontrakt --</option>
<option value="">-- Vælg servicekontrakt --</option>
</select>
</div>
<div class="d-flex gap-2">
@ -155,7 +155,7 @@
</button>
</div>
</div>
<div id="filterHint" class="small text-muted mt-2">Start med at vaelge en kunde.</div>
<div id="filterHint" class="small text-muted mt-2">Start med at vælge en kunde.</div>
</div>
<div id="errorBox" class="alert alert-danger d-none" role="alert"></div>
@ -221,7 +221,7 @@
customerSelect.appendChild(option);
}
} catch (error) {
showError(error.message || 'Fejl ved indlaesning af kunder');
showError(error.message || 'Fejl ved indlæsning af kunder');
}
}
@ -229,14 +229,14 @@
state.selectedAccountId = event.target.value || '';
state.selectedContractId = '';
reportSection.classList.add('d-none');
contractSelect.innerHTML = '<option value="">-- Vaelg servicekontrakt --</option>';
contractSelect.innerHTML = '<option value="">-- Vælg servicekontrakt --</option>';
contractSelect.disabled = !state.selectedAccountId;
loadBtn.disabled = true;
pdfBtn.disabled = true;
excelBtn.disabled = true;
if (!state.selectedAccountId) {
filterHint.textContent = 'Start med at vaelge en kunde.';
filterHint.textContent = 'Start med at vælge en kunde.';
return;
}
@ -260,7 +260,7 @@
}
filterHint.textContent = state.contracts.length
? 'Vaelg servicekontrakt og hent rapporten.'
? 'Vælg servicekontrakt, og hent rapporten.'
: 'Ingen servicekontrakter fundet for kunden.';
} catch (error) {
showError(error.message || 'Fejl ved hentning af servicekontrakter');
@ -312,7 +312,7 @@
document.getElementById('reportContractMeta').textContent = `${customer.account_name} (${customer.account_id})`;
const contractLinkEl = document.getElementById('reportContractLink');
if (contract.vtiger_url) {
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn servicekontrakt i vTiger</a>`;
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Åbn servicekontrakt i vTiger</a>`;
} else {
contractLinkEl.textContent = '';
}
@ -354,7 +354,7 @@
<div class="case-title">${escapeHtml(caseItem.title || '-')}</div>
<div class="small text-muted">CC-nummer: ${escapeHtml(caseItem.cc_number || caseItem.id || '-')}</div>
<div class="small text-muted">Kontaktperson: ${escapeHtml(caseItem.contact_person || '-')}</div>
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn case i vTiger</a>` : ''}</div>
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Åbn sag i vTiger</a>` : ''}</div>
<div class="small mt-1">${escapeHtml(caseItem.description || 'Ingen beskrivelse')}</div>
</div>
<div class="small text-end">

View File

@ -210,7 +210,7 @@
<div class="tab-pane fade" id="fakturaer">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="mb-0 fw-bold">Leverandør Fakturaer</h5>
<h5 class="mb-0 fw-bold">Leverandørfakturaer</h5>
<span class="badge bg-primary" id="invoiceCount">0</span>
</div>
<div class="table-responsive">

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS contact_merge_history (
id BIGSERIAL PRIMARY KEY,
target_contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
source_contact_id INTEGER NOT NULL,
source_snapshot JSONB NOT NULL,
moved_relations JSONB NOT NULL DEFAULT '{}'::jsonb,
merged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contact_merge_history_target
ON contact_merge_history(target_contact_id, merged_at DESC);
COMMENT ON TABLE contact_merge_history IS
'Audit trail for destructive contact merges. Source contact snapshots and moved relation counts are retained.';

View File

@ -2,6 +2,7 @@
let latestSections = {};
let latestContextActions = { global: [], context: [] };
let activeKey = 'timer';
let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' };
let overviewFilter = null;
let ws = null;
let pollTimer = null;
@ -17,7 +18,7 @@
let switchCaseState = {
activeTimer: null,
decision: 'unchanged',
timers: { active: [], paused: [] },
timers: { active: [], paused: [], stopped: [] },
recentCases: [],
unassignedCases: []
};
@ -855,10 +856,10 @@
if (key === 'overview') {
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '<div><strong class="text-danger"><i class="bi bi-exclamation-octagon"></i> Hastesag:</strong> ' + esc(u.title) + ' <br><button class="btn btn-sm btn-outline-danger mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Vis sag</button></div>') : ['Ingen hastesager.'];
if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>') : ['Alle systemer oppe.'];
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>') : ['Ingen ESET incidents.'];
if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <a class="btn btn-sm btn-outline-primary" href="/drift">Håndter i Drift</a></div>') : ['Alle systemer oppe.'];
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <a class="btn btn-sm btn-outline-primary" href="/hardware/eset">Håndter</a></div>') : ['Ingen ESET incidents.'];
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '<div><i class="bi bi-folder2-open text-primary"></i> ' + esc(c.title) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(c.id || 0) + '">Vis sag</button></div>') : ['Ingen åbne sager.'];
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <button class="btn btn-sm btn-outline-primary mt-2">Åbn indbakke</button></div>'];
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <a class="btn btn-sm btn-outline-primary mt-2" href="/emails">Åbn indbakke</a></div>'];
if (overviewFilter === 'unassigned') return unassigned.list ? unassigned.list.map(u => '<div><i class="bi bi-person-x text-warning"></i> ' + esc(u.title || ('Sag #' + (u.id || ''))) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Åbn sag</button></div>') : ['Ingen åbne sager uden ansvarlig.'];
let out = [];
@ -872,6 +873,15 @@
out.push('<div>🎉 Alt ser grønt ud! Intet kritisk lige nu.</div>');
}
const contextActions = (latestContextActions.context || []);
const globalActions = (latestContextActions.global || []);
const smartActions = contextActions.length ? contextActions : globalActions;
if (smartActions.length) {
out.push('<div class="mt-3 pt-3 border-top"><div class="small fw-semibold text-muted mb-2">Smarte handlinger</div><div class="d-flex flex-wrap gap-2">' + smartActions.map(function (action) {
return '<button type="button" class="btn btn-sm btn-outline-primary" data-bb-create="' + esc(action.id) + '"><i class="bi ' + esc(action.icon || 'bi-lightning-charge') + ' me-1"></i>' + esc(action.label) + '</button>';
}).join('') + '</div></div>');
}
// Add quick note button on overview
out.push('<div class="mt-3 pt-3 border-top"><div class="input-group"><input type="text" id="bbQuickNoteInput" class="form-control form-control-sm" placeholder="Skriv en quick note..." value="' + quickNoteValue + '"><button class="btn btn-outline-secondary btn-sm" id="bbQuickNoteSaveBtn"><i class="bi bi-pencil"></i> Gem note</button></div><div id="bbQuickNoteHint" class="small text-muted mt-2">' + esc(quickNoteHintState.message || 'Tip: gemmer som kommentar på aktiv/åben sag.') + '</div></div>');
@ -882,10 +892,10 @@
if (timer.active_count > 0) {
return (timer.list || []).map(t => {
const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's');
return '<div class="d-flex justify-content-between align-items-center"><span><i class="bi bi-stopwatch text-success"></i> ' + esc(t.desc) + ' (' + esc(elapsedText) + ')</span> <button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill"></i> Stop</button></div>';
return '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
});
}
return ['Ingen aktive timere lige nu.'];
return ['<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen aktiv timer</strong><div class="small mt-1">Brug “Skift sag” i bundlinjen for at starte arbejdet.</div></div></div>'];
}
if (key === 'messages') {
@ -929,9 +939,9 @@
if (key === 'tasks') {
if (tasks.count > 0) {
return (tasks.list || []).map(t => '<div><i class="bi bi-calendar-check text-success"></i> <strong>' + esc(t.title) + '</strong> <span class="badge bg-secondary ms-2">' + esc(t.deadline) + '</span></div>');
return (tasks.list || []).map(t => '<div class="d-flex align-items-center gap-2 min-w-0"><span class="rounded-circle bg-success-subtle text-success p-2"><i class="bi bi-check2"></i></span><div class="min-w-0"><strong class="d-block text-truncate">' + esc(t.title) + '</strong><span class="small text-muted">Prioritet: ' + esc(t.deadline) + '</span></div></div><span class="badge rounded-pill text-bg-light border">Aktuel</span>');
}
return ['Ingen aktuelle opgaver.'];
return ['<div class="bb-panel-empty"><div><i class="bi bi-check2-circle"></i><strong>Du er ajour</strong><div class="small mt-1">Der er ingen aktuelle opgaver eller påmindelser.</div></div></div>'];
}
if (key === 'notes') {
@ -1153,8 +1163,12 @@
const timerChip = byId('bbActiveTimerChip');
const timerText = byId('bbActiveTimerText');
const notifCount = byId('bbNotificationsCount');
const pauseBtn = byId('bbTimerPauseBtn');
const stopBtn = byId('bbTimerStopBtn');
const timer = ((latestSections || {}).timer || {}).active || {};
const ownTimers = ((latestSections || {}).timer || {}).own || {};
const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0;
const hasActiveTimer = !!timer.active;
if (timerChip && timerText) {
timerChip.classList.toggle('is-hidden', !hasActiveTimer);
@ -1170,6 +1184,15 @@
const computed = Number(latestNotificationCount || 0) + unreadMessages;
notifCount.textContent = String(computed);
}
if (pauseBtn) {
pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer;
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag senest pausede timer' : 'Ingen timer at pause');
pauseBtn.innerHTML = hasActiveTimer ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>';
}
if (stopBtn) {
stopBtn.disabled = !hasActiveTimer;
stopBtn.title = hasActiveTimer ? 'Stop timer' : 'Ingen aktiv timer';
}
}
function renderTabPanel() {
@ -1193,6 +1216,7 @@
}
const titleText = titleContainer.querySelector('.bb-tab-title-text');
const descriptionEl = byId('bbTabDescription');
const titleByKey = {
overview: 'Overblik',
@ -1212,12 +1236,24 @@
boss: 'bi-person-workspace'
};
const descriptionByKey = {
overview: 'Det vigtigste samlet ét sted.',
timer: 'Se aktiv tid, stop registreringen eller skift direkte til en anden sag.',
messages: 'Interne samtaler samlet efter modtager med tydelig læst-status.',
tasks: 'Prioritér næste handling ud fra deadlines og aktuelle påmindelser.',
notes: 'Skriv hurtigt til venstre og genbrug dine noter fra arkivet til højre.',
boss: 'Fordel supportkøen ud fra kapacitet, hast og ventetid.'
};
const activeTitle = titleByKey[activeKey] || 'Info';
if (titleText) {
titleText.textContent = activeTitle;
} else {
titleContainer.textContent = activeTitle;
}
if (descriptionEl) {
descriptionEl.textContent = descriptionByKey[activeKey] || '';
}
const iconSpan = titleContainer.querySelector('.bi');
if (iconSpan) {
@ -1228,6 +1264,7 @@
const lines = listFor(activeKey, latestSections);
const ul = document.createElement('ul');
ul.className = 'bb-tab-list';
ul.classList.add('bb-panel-' + activeKey);
lines.forEach(function (line) {
const li = document.createElement('li');
@ -1238,11 +1275,16 @@
innerContent.innerHTML = '';
if (activeKey === 'timer') {
renderTimerWorkQueue(innerContent);
return;
}
// Add specific headers/controls based on active tab
if (activeKey === 'tasks') {
const topBar = document.createElement('div');
topBar.className = 'bb-task-actions mb-3';
topBar.innerHTML = '<button class="btn btn-primary btn-sm w-100 fw-bold shadow-sm" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right"></i> Giv mig næste opgave</button>';
topBar.innerHTML = '<button class="btn btn-primary btn-sm fw-bold" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right me-1"></i>Giv mig næste opgave</button>';
innerContent.appendChild(topBar);
}
if (activeKey === 'messages') {
@ -1393,6 +1435,105 @@
}
function timerStateLabel(state) {
if (state === 'active') return ['Aktiv', 'success'];
if (state === 'paused') return ['Pauset', 'warning'];
return ['Klar til registrering', 'info'];
}
function formatTimerSeconds(value) {
const seconds = Math.max(0, Number(value || 0));
return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), Math.floor(seconds % 60)]
.map(function (part) { return String(part).padStart(2, '0'); }).join(':');
}
function renderTimerWorkQueue(container) {
const scope = timerPanelState.scope;
const toolbar = document.createElement('div');
toolbar.className = 'd-flex justify-content-between align-items-center gap-2 mb-3';
toolbar.innerHTML = '<div class="btn-group btn-group-sm" role="group" aria-label="Timervisning">' +
'<button class="btn ' + (scope === 'mine' ? 'btn-primary' : 'btn-outline-secondary') + '" data-bb-timer-scope="mine">Mine timere</button>' +
'<button class="btn ' + (scope === 'all' ? 'btn-primary' : 'btn-outline-secondary') + '" data-bb-timer-scope="all">Alle medarbejdere</button></div>' +
'<span class="small text-muted">' + timerPanelState.rows.length + ' vist</span>';
container.appendChild(toolbar);
if (timerPanelState.loading || !timerPanelState.loaded) {
const loading = document.createElement('div');
loading.className = 'bb-panel-empty';
loading.innerHTML = '<div><span class="spinner-border spinner-border-sm me-2"></span>Henter timere...</div>';
container.appendChild(loading);
if (!timerPanelState.loading) loadTimerWorkQueue(scope);
return;
}
if (timerPanelState.error) {
const errorBox = document.createElement('div');
errorBox.className = 'alert alert-warning d-flex justify-content-between align-items-center gap-3';
errorBox.innerHTML = '<span><i class="bi bi-exclamation-triangle me-2"></i>' + escapeHtml(timerPanelState.error) + '</span>' +
'<button class="btn btn-sm btn-outline-dark" data-bb-timer-scope="' + scope + '">Prøv igen</button>';
container.appendChild(errorBox);
}
const list = document.createElement('div');
list.className = 'bb-timer-work-grid';
if (!timerPanelState.rows.length) {
list.innerHTML = '<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen timere at vise</strong></div></div>';
} else {
list.innerHTML = timerPanelState.rows.map(function (row) {
const state = String(row.timer_state || 'pending_conversion');
const label = timerStateLabel(state);
const timeId = Number(row.id || row.time_entry_id || 0);
const sagId = Number(row.sag_id || 0);
const isOwn = row.is_own_timer === true;
let action = '';
if (isOwn && state === 'paused') action = '<button class="btn btn-sm btn-primary" data-bb-resume-time="' + timeId + '"><i class="bi bi-play-fill me-1"></i>Genoptag</button>';
if (isOwn && state === 'pending_conversion') action = '<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '"><i class="bi bi-arrow-repeat me-1"></i>Konverter</button>';
if (isOwn && state === 'active') action = '<button class="btn btn-sm btn-danger" data-bb-stop-time="' + timeId + '"><i class="bi bi-stop-fill me-1"></i>Stop</button>';
const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed);
const elapsed = state === 'active' ? '<span class="font-monospace">' + escapeHtml(elapsedValue) + '</span> · ' : '';
const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder');
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
}).join('');
}
container.appendChild(list);
}
function ownTimerRows(payload) {
const groups = normalizeSwitchableTimerPayload(payload);
return groups.active.map(function (row) {
return Object.assign({}, row, { timer_state: 'active', is_own_timer: true });
}).concat(groups.paused.map(function (row) {
return Object.assign({}, row, { timer_state: 'paused', is_own_timer: true });
}), groups.stopped.map(function (row) {
return Object.assign({}, row, { timer_state: 'pending_conversion', is_own_timer: true });
}));
}
async function loadTimerWorkQueue(scope) {
timerPanelState.scope = scope || 'mine';
timerPanelState.loading = true;
timerPanelState.error = '';
try {
if (timerPanelState.scope === 'mine') {
const ownTimers = await fetchSwitchableTimers();
timerPanelState.rows = ownTimerRows(ownTimers);
switchCaseState.timers.stopped = timerPanelState.rows.filter(function (row) { return row.timer_state === 'pending_conversion'; });
} else {
const response = await fetch('/api/v1/timetracking/time/team-status?scope=all', { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente alle medarbejderes timere');
const payload = await response.json();
timerPanelState.rows = Array.isArray(payload.rows) ? payload.rows : [];
}
timerPanelState.loaded = true;
} catch (err) {
timerPanelState.error = err && err.message ? err.message : 'Kunne ikke hente timeroversigten';
timerPanelState.loaded = true;
} finally {
timerPanelState.loading = false;
if (activeKey === 'timer') renderTabPanel();
}
}
function bindSideTabs() {
const buttons = document.querySelectorAll('.bb-tab-btn');
for (let i = 0; i < buttons.length; i++) {
@ -1680,14 +1821,15 @@
}
function normalizeSwitchableTimerPayload(payload) {
const out = { active: [], paused: [] };
const out = { active: [], paused: [], stopped: [] };
if (!payload || typeof payload !== 'object') {
return out;
}
if (Array.isArray(payload.active) || Array.isArray(payload.paused)) {
if (Array.isArray(payload.active) || Array.isArray(payload.paused) || Array.isArray(payload.stopped)) {
out.active = Array.isArray(payload.active) ? payload.active : [];
out.paused = Array.isArray(payload.paused) ? payload.paused : [];
out.stopped = Array.isArray(payload.stopped) ? payload.stopped : [];
return out;
}
@ -1762,6 +1904,32 @@
return sagId > 0 ? ('Sag #' + sagId) : 'Ukendt sag';
}
function timerDurationLabel(timer) {
let seconds = Number((timer && (timer.live_elapsed_seconds || timer.elapsed_seconds || timer.elapsed)) || 0);
if (!seconds && timer && timer.start_tid && timer.slut_tid) {
const start = new Date(timer.start_tid).getTime();
const end = new Date(timer.slut_tid).getTime();
if (Number.isFinite(start) && Number.isFinite(end)) {
seconds = Math.max(0, Math.floor((end - start) / 1000) - Number(timer.pause_total_seconds || 0));
}
}
if (!seconds && timer && timer.faktisk_tid_min) seconds = Number(timer.faktisk_tid_min) * 60;
return formatTimerSeconds(seconds);
}
function timerCaseInfo(timer, includeDuration) {
const sagId = Number((timer && timer.sag_id) || 0);
const customer = String((timer && timer.customer_name) || 'Ingen kunde');
const contact = String((timer && timer.contact_name) || 'Ingen kontakt');
const status = String((timer && timer.case_status) || 'Ukendt status');
return '<strong class="bb-switch-info-title">' + timerDisplayName(timer) + '</strong>' +
'<span class="bb-switch-info-meta">#' + sagId + '</span>' +
'<span class="bb-switch-info-meta"><i class="bi bi-building me-1"></i>' + escapeHtml(customer) + '</span>' +
'<span class="bb-switch-info-meta"><i class="bi bi-person me-1"></i>' + escapeHtml(contact) + '</span>' +
'<span class="badge rounded-pill text-bg-light border bb-switch-status">' + escapeHtml(status) + '</span>' +
(includeDuration ? '<span class="bb-switch-info-meta"><i class="bi bi-stopwatch me-1"></i>' + timerDurationLabel(timer) + '</span>' : '');
}
function renderSwitchCaseLists() {
const timersEl = byId('bbSwitchTimersList');
const recentEl = byId('bbSwitchRecentCasesList');
@ -1772,43 +1940,54 @@
const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : [];
const paused = Array.isArray(switchCaseState.timers.paused) ? switchCaseState.timers.paused : [];
const stopped = Array.isArray(switchCaseState.timers.stopped) ? switchCaseState.timers.stopped : [];
const recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : [];
const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : [];
const showUnassigned = unassignedCases.length > 0;
const searchEl = byId('bbSwitchCaseSearch');
const query = String((searchEl && searchEl.value) || '').trim().toLocaleLowerCase('da-DK');
if (actionsEl) {
actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer);
}
if (!active.length && !paused.length) {
timersEl.innerHTML = '<div class="list-group-item text-muted">Ingen aktive eller pausede timere.</div>';
if (!active.length && !paused.length && !stopped.length) {
timersEl.innerHTML = '<div class="bb-switch-empty">Ingen tidligere timere at fortsætte.</div>';
} else {
let timerItems = '';
active.forEach(function (t) {
const timeId = Number((t && (t.id || t.time_entry_id)) || 0);
timerItems +=
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
'<div><span class="badge text-bg-success me-2">Aktiv</span>' + timerDisplayName(t) + '</div>' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</button>' +
'</div>';
if (timeId > 0) {
timerItems +=
'<div class="list-group-item small text-muted border-top-0 pt-0">Timer ID: ' + timeId + '</div>';
}
'<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-success">Aktiv</span>' + timerCaseInfo(t, true) + '</div>' +
'<button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button></div>';
});
paused.forEach(function (t) {
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
timerItems +=
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
'<div><span class="badge text-bg-warning me-2">Pauset</span>' + timerDisplayName(t) + '</div>' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</button>' +
'</div>';
'<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-warning">Pauset</span>' + timerCaseInfo(t, true) + '</div>' +
'<div class="bb-switch-case-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button>' +
'<button class="btn btn-sm btn-primary" data-bb-resume-time="' + timeId + '"><i class="bi bi-play-fill me-1"></i>Genoptag</button></div></div>';
});
stopped.forEach(function (t) {
const sagId = Number((t && t.sag_id) || 0);
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
timerItems +=
'<div class="bb-switch-timer bb-switch-timer-pending">' +
'<div class="bb-switch-timer-info">' + timerCaseInfo(t, true) + '</div>' +
'<div class="bb-switch-case-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' +
'<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '">Registrer</button></div></div>';
});
timersEl.innerHTML = timerItems;
}
const sourceCases = showUnassigned ? unassignedCases : recentCases;
const sourceCases = (showUnassigned ? unassignedCases : recentCases).filter(function (row) {
if (!query) return true;
const searchable = String((row && (row.sag_id || row.id)) || '') + ' ' + String((row && (row.titel || row.title)) || '');
return searchable.toLocaleLowerCase('da-DK').includes(query);
});
const titleEl = byId('bbSwitchCaseModalLabel');
if (titleEl) {
titleEl.innerHTML = showUnassigned
@ -1817,26 +1996,94 @@
}
if (!sourceCases.length) {
recentEl.innerHTML = '<div class="list-group-item text-muted">Ingen sager at vise.</div>';
recentEl.innerHTML = '<div class="bb-switch-empty"><i class="bi bi-search me-1"></i>' + (query ? 'Ingen sager matcher din søgning.' : 'Ingen sager at vise.') + '</div>';
return;
}
recentEl.innerHTML = sourceCases.map(function (row) {
const caseId = Number((row && (row.sag_id || row.id)) || 0);
const title = escapeHtml((row && (row.titel || row.title)) || (caseId > 0 ? ('Sag #' + caseId) : 'Ukendt sag'));
const prefix = showUnassigned ? '<span class="badge text-bg-warning me-2">Uden ansvarlig</span>' : '<span class="badge text-bg-light border me-2">Senest</span>';
const meta = showUnassigned ? '<span class="text-warning">Uden ansvarlig</span>' : 'Senest anvendt';
return (
'<div class="list-group-item d-flex justify-content-between align-items-start gap-2">' +
'<div class="me-2">' + prefix + title + '</div>' +
'<div class="d-flex gap-1">' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + caseId + '">Åbn</button>' +
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '">Start timer</button>' +
'<div class="bb-switch-case">' +
'<div class="bb-switch-case-main"><div class="bb-switch-case-title">' + title + '</div><div class="bb-switch-case-meta">Sag #' + caseId + ' · ' + meta + '</div></div>' +
'<div class="bb-switch-case-actions">' +
'<button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + caseId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button>' +
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '"><i class="bi bi-play-fill me-1"></i>Start</button>' +
'</div>' +
'</div>'
);
}).join('');
}
async function openTimeConversion(timeId) {
const timer = (switchCaseState.timers.stopped || []).concat(timerPanelState.rows || []).find(function (row) {
return Number((row && (row.time_entry_id || row.id)) || 0) === Number(timeId);
});
if (!timer) return;
const panel = byId('bbConvertTimePanel');
const select = byId('bbConvertBillingMethod');
if (!panel || !select) return;
panel.dataset.timeId = String(timeId);
byId('bbConvertTimeName').textContent = String((timer.sag_navn || timer.title) || ('Sag #' + (timer.sag_id || '')));
byId('bbConvertWorkType').value = timer.work_type || 'support';
byId('bbConvertMinutes').value = Number(timer.fakturerbar_tid_min != null ? timer.fakturerbar_tid_min : (timer.faktisk_tid_min || 0));
select.innerHTML = '<option>Henter muligheder...</option>';
panel.classList.remove('d-none');
try {
const response = await fetch('/api/v1/timetracking/time/' + Number(timeId) + '/settlement-options', { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente afregningsmuligheder');
const options = await response.json();
const recommended = options.recommended || { method: 'invoice' };
let html = '<option value="invoice">Faktura</option>';
(options.prepaid_cards || []).forEach(function (card) {
html += '<option value="prepaid:' + Number(card.id) + '">Klippekort #' + escapeHtml(card.card_number || card.id) + ' (' + Number(card.remaining_hours || 0).toFixed(2) + ' t tilbage)</option>';
});
(options.agreements || []).forEach(function (agreement) {
html += '<option value="subscription:' + Number(agreement.id) + '">Abonnement #' + escapeHtml(agreement.agreement_number || agreement.id) + '</option>';
});
html += '<option value="internal">Intern tid</option><option value="non_billable">Ikke fakturerbar</option>';
select.innerHTML = html;
const recommendedId = recommended.prepaid_card_id || recommended.fixed_price_agreement_id;
select.value = recommended.method + (recommendedId ? ':' + recommendedId : '');
byId('bbConvertRecommendation').textContent = 'Foreslået: ' + (recommended.reason || 'Faktura');
} catch (err) {
select.innerHTML = '<option value="invoice">Faktura</option><option value="internal">Intern tid</option>';
byId('bbConvertRecommendation').textContent = err.message || 'Kunne ikke hente forslag.';
}
}
async function submitTimeConversion() {
const panel = byId('bbConvertTimePanel');
const button = byId('bbConvertSubmit');
const timeId = Number((panel && panel.dataset.timeId) || 0);
if (!timeId || !button) return;
const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':');
const payload = {
billing_method: selected[0],
work_type: byId('bbConvertWorkType').value,
fakturerbar_tid_min: Math.max(0, Number(byId('bbConvertMinutes').value || 0)),
entry_type: 'manuel'
};
if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]);
if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]);
button.disabled = true;
try {
const response = await fetch('/api/v1/timetracking/time/' + timeId + '/approve', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const result = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden');
panel.classList.add('d-none');
await loadSwitchCaseData();
if (activeKey === 'timer') await loadTimerWorkQueue(timerPanelState.scope);
} catch (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke konvertere tiden.'));
} finally {
button.disabled = false;
}
}
async function loadSwitchCaseData(options) {
const opts = options || {};
switchCaseState.decision = 'unchanged';
@ -1845,7 +2092,7 @@
: null;
switchCaseState.unassignedCases = [];
switchCaseState.recentCases = [];
switchCaseState.timers = { active: [], paused: [] };
switchCaseState.timers = { active: [], paused: [], stopped: [] };
if (opts.onlyUnassigned) {
const unassigned = (((latestSections || {}).unassigned || {}).list || []);
@ -1937,7 +2184,7 @@
if (modal) {
modal.hide();
}
window.location.href = '/sag/' + validCaseId;
window.location.href = '/sag/' + validCaseId + '/v3';
} catch (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke starte timer for sag.'));
}
@ -1952,11 +2199,41 @@
if (modal) {
modal.hide();
}
window.location.href = '/sag/' + validCaseId;
window.location.href = '/sag/' + validCaseId + '/v3';
}
function executeBottomBarAction(action) {
if (!action) return false;
const command = String(action.command || '');
if (command.indexOf('case_add:') === 0) {
const caseAction = command.slice('case_add:'.length);
if (typeof window.openCaseModuleAddPanel === 'function' && typeof window.openCaseAddAction === 'function') {
Promise.resolve(window.openCaseModuleAddPanel()).then(function () {
return window.openCaseAddAction(caseAction);
});
setExpanded(false);
return true;
}
}
if (command === 'switch_timer') {
openSwitchCaseModal();
return true;
}
if (command === 'open_notes') {
activeKey = 'notes';
setExpanded(true);
renderTabPanel();
return true;
}
if (action.action) {
window.location.href = action.action;
return true;
}
return false;
}
function resolveQuickNoteCaseId() {
const match = (window.location.pathname || '').match(/^\/sag\/(\d+)$/);
const match = (window.location.pathname || '').match(/^\/sag\/(\d+)(?:\/v3)?\/?$/);
if (match && match[1]) {
return Number(match[1]);
}
@ -2319,13 +2596,6 @@
if (notificationsBtn) {
notificationsBtn.addEventListener('click', function () {
if (latestNotifications.length > 0) {
const first = latestNotifications[0] || {};
if (first.action) {
window.location.href = first.action;
return;
}
}
const trigger = byId('globalRemindersBtn');
if (trigger) {
trigger.click();
@ -2378,7 +2648,9 @@
if (timerChip) {
timerChip.addEventListener('click', function () {
window.location.href = '/timetracking';
const timer = (((latestSections || {}).timer || {}).active || {});
const sagId = Number(timer.sag_id || 0);
window.location.href = sagId > 0 ? ('/sag/' + sagId) : '/timetracking';
});
}
@ -2397,15 +2669,26 @@
return;
}
}
if (matched && matched.action) {
window.location.href = matched.action;
}
executeBottomBarAction(matched);
});
}
function bindDynamicActions() {
document.addEventListener('click', function (e) {
const target = e.target;
const timerScopeButton = target && target.closest('[data-bb-timer-scope]');
if (timerScopeButton) {
timerPanelState.loaded = false;
loadTimerWorkQueue(timerScopeButton.getAttribute('data-bb-timer-scope') || 'mine');
renderTabPanel();
return;
}
const caseLink = target && target.closest('[data-bb-case-link]');
if (caseLink && !target.closest('button, a, input, select, textarea')) {
const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0);
if (sagId > 0) openCaseDetail(sagId);
return;
}
const btn = target && target.closest('button');
if (!btn) return;
@ -2661,12 +2944,47 @@
}
}
if (btn.hasAttribute('data-bb-cancel-convert')) {
const panel = byId('bbConvertTimePanel');
if (panel) panel.classList.add('d-none');
return;
}
if (btn.id === 'bbConvertSubmit') {
submitTimeConversion();
return;
}
const convertTimeId = Number(btn.getAttribute('data-bb-convert-time') || 0);
if (convertTimeId > 0) {
openTimeConversion(convertTimeId);
return;
}
const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0);
if (openCaseId > 0) {
openCaseDetail(openCaseId);
return;
}
const resumeTimeId = Number(btn.getAttribute('data-bb-resume-time') || 0);
if (resumeTimeId > 0) {
btn.disabled = true;
switchCaseStatusMessage('<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Genoptager timer...');
resumeTimer(resumeTimeId)
.then(fetchBottomBarState)
.then(function (state) {
applyState(state);
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timeren er genoptaget.');
return loadSwitchCaseData();
})
.catch(function (err) {
btn.disabled = false;
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke genoptage timer.'));
});
return;
}
const startCaseId = Number(btn.getAttribute('data-bb-start-case') || 0);
if (startCaseId > 0) {
startTimerForCase(startCaseId);
@ -2878,7 +3196,7 @@
}
if (bossAction === 'open_case') {
const caseId = Number(btn.getAttribute('data-case-id') || 0);
window.location.href = caseId > 0 ? ('/sag/' + caseId) : '/sag';
window.location.href = caseId > 0 ? ('/sag/' + caseId + '/v3') : '/sag';
return;
}
}
@ -2998,6 +3316,10 @@
document.addEventListener('input', function (e) {
const target = e.target;
if (target && target.id === 'bbSwitchCaseSearch') {
renderSwitchCaseLists();
return;
}
if (!target || target.id !== 'bbQuickNoteInput') {
if (target && target.id === 'bbNoteTitleInput') {
noteEditorState.title = String(target.value || '');

View File

@ -32,7 +32,7 @@
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-file-earmark-check me-2"></i>Vaelg opgave-template</h5>
<h5 class="modal-title"><i class="bi bi-file-earmark-check me-2"></i>Vælg opgave-template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@ -40,9 +40,9 @@
<div class="col-lg-4">
<label class="form-label">Template-kilde</label>
<select id="ttSource" class="form-select">
<option value="all" selected>Firma + faelles</option>
<option value="all" selected>Firma + fælles</option>
<option value="company">Kun firma</option>
<option value="global">Kun faelles</option>
<option value="global">Kun fælles</option>
<option value="internal">Kun intern</option>
</select>
</div>
@ -54,7 +54,7 @@
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="brugerandring">Brugerændring</option>
<option value="andet">Andet</option>
</select>
</div>
@ -65,7 +65,7 @@
<div class="col-lg-8">
<label class="form-label">Template</label>
<input id="ttTemplateSearch" class="form-control mb-2" placeholder="Soeg template..." />
<input id="ttTemplateSearch" class="form-control mb-2" placeholder="Søg template..." />
<select id="ttTemplate" class="form-select" size="8"></select>
<div id="ttTemplateEmpty" class="small text-muted mt-2 d-none">Ingen templates matchede dit filter.</div>
</div>
@ -74,15 +74,15 @@
<label class="form-label">Oprettelsestype</label>
<select id="ttMode" class="form-select mb-3">
<option value="subcases">Opret som undersager</option>
<option value="tasks">Opret som tasks paa nuvaerende sag</option>
<option value="tasks">Opret som opgaver nuværende sag</option>
<option value="combined" selected>Kombineret</option>
</select>
<label class="form-label">Ansvarlig</label>
<select id="ttAssigneeMode" class="form-select mb-2">
<option value="template_default" selected>Brug standard fra template</option>
<option value="specific_user">Vaelg specifik medarbejder</option>
<option value="specific_role">Vaelg team/rolle</option>
<option value="specific_user">Vælg specifik medarbejder</option>
<option value="specific_role">Vælg team/rolle</option>
</select>
<input id="ttAssigneeUserId" type="number" class="form-control mb-2 d-none" placeholder="Medarbejder ID" min="1" step="1" />
<input id="ttAssigneeRoleId" type="number" class="form-control d-none" placeholder="Rolle ID" min="1" step="1" />
@ -100,10 +100,10 @@
<div class="border-top mt-4 pt-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Seneste template-koersler paa sagen</h6>
<h6 class="mb-0">Seneste template-kørsler sagen</h6>
<button type="button" class="btn btn-sm btn-outline-secondary" id="ttRefreshRunsBtn">Opdater</button>
</div>
<div id="ttRunHistory" class="small text-muted">Ingen template-koersler endnu.</div>
<div id="ttRunHistory" class="small text-muted">Ingen template-kørsler endnu.</div>
</div>
</div>
<div class="modal-footer">
@ -194,7 +194,7 @@
});
select.innerHTML = filtered.map((template) => {
const scopeLabel = template.template_type === 'company' ? 'Firma' : template.template_type === 'global' ? 'Faelles' : 'Intern';
const scopeLabel = template.template_type === 'company' ? 'Firma' : template.template_type === 'global' ? 'Fælles' : 'Intern';
const cat = template.category || 'andet';
return `<option value="${template.id}">${template.name} [${scopeLabel}] (${cat})</option>`;
}).join('');
@ -225,7 +225,7 @@
function buildPayload() {
const templateId = Number(document.getElementById('ttTemplate').value);
if (!templateId) {
throw new Error('Vaelg en template');
throw new Error('Vælg en template');
}
const mode = document.getElementById('ttMode').value;
@ -312,7 +312,7 @@
async function runTemplate() {
if (!currentCaseId) return;
if (!currentPreview) {
notify('Koer preview foerst', 'error');
notify('Kør forhåndsvisning først', 'error');
return;
}
@ -332,11 +332,11 @@
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail || 'Koersel fejlede');
throw new Error(body.detail || 'Kørsel fejlede');
}
const result = await response.json();
notify(`Template koert. Oprettet ${result.summary?.tasks || 0} tasks og ${result.summary?.subcases || 0} undersager.`, 'success');
notify(`Template kørt. Oprettet ${result.summary?.tasks || 0} opgaver og ${result.summary?.subcases || 0} undersager.`, 'success');
if (typeof window.syncCaseTagsUi === 'function') {
window.syncCaseTagsUi();
@ -347,10 +347,10 @@
await loadRunHistory();
document.getElementById('ttRunBtn').disabled = true;
document.getElementById('ttPreviewSummary').textContent = 'Template koert. Vaelg en ny template eller opdater preview igen.';
document.getElementById('ttPreviewSummary').textContent = 'Template kørt. Vælg en ny template eller opdater forhåndsvisningen igen.';
} catch (error) {
console.error('Run failed:', error);
notify(error.message || 'Koersel fejlede', 'error');
notify(error.message || 'Kørsel fejlede', 'error');
} finally {
runButton.disabled = false;
runButton.textContent = originalText;
@ -383,7 +383,7 @@
const container = document.getElementById('ttRunHistory');
if (!container || !currentCaseId) return;
container.innerHTML = '<div class="text-muted">Indlaeser historik...</div>';
container.innerHTML = '<div class="text-muted">Indlæser historik...</div>';
try {
const response = await fetch(`/api/v1/cases/${currentCaseId}/template-runs?limit=10`, {
@ -397,7 +397,7 @@
const rows = await response.json();
if (!Array.isArray(rows) || rows.length === 0) {
container.innerHTML = '<div class="text-muted">Ingen template-koersler endnu.</div>';
container.innerHTML = '<div class="text-muted">Ingen template-kørsler endnu.</div>';
return;
}
@ -421,7 +421,7 @@
<div class="small mt-1">Oprettet: ${taskCount} opgaver, ${subcaseCount} undersager</div>
${run.template_id ? `
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="window.reuseCaseTemplateFromHistory(${Number(run.template_id)})">Vaelg igen</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="window.reuseCaseTemplateFromHistory(${Number(run.template_id)})">Vælg igen</button>
</div>
` : ''}
${run.error_message ? `<div class="small text-danger mt-1">${escapeHtml(run.error_message)}</div>` : ''}

View File

@ -0,0 +1,44 @@
from pathlib import Path
UI_FILES = [
"app/auth/frontend/login.html",
"app/auth/frontend/2fa_setup.html",
"app/dashboard/frontend/mission_control.html",
"app/dashboard/frontend/mission_control_v2.html",
"app/economy/frontend/time_queue.html",
"app/modules/calendar/templates/index.html",
"app/modules/hardware/templates/detail.html",
"app/modules/hardware/templates/eset_import.html",
"app/modules/sag/templates/detail_v3.html",
"app/modules/sag/templates/index.html",
"app/products/frontend/detail.html",
"app/products/frontend/list.html",
"app/settings/frontend/settings.html",
"app/settings/frontend/migrations.html",
"app/subscriptions/frontend/list.html",
"app/timetracking/frontend/employee_log.html",
"app/timetracking/frontend/service_contract_report.html",
"static/js/task-template-selector.js",
]
def test_active_danish_ui_uses_danish_characters_in_common_words():
source = "\n".join(Path(filename).read_text() for filename in UI_FILES)
forbidden_visible_spellings = [
"Aabn ",
"Aabne sager",
"Indlaeser",
"Maaned",
"Netvaerksfejl",
"Opsaet ",
"Paakraevet",
"Soeger...",
"Tilfoej",
"Vaelg ",
"leverandoer",
"paakraevet",
]
for spelling in forbidden_visible_spellings:
assert spelling not in source, f"ASCII-dansk fundet i UI: {spelling}"

View File

@ -0,0 +1,15 @@
from pathlib import Path
def test_case_multisearch_includes_and_labels_archived_cases():
router_source = Path("app/dashboard/backend/router.py").read_text()
base_template = Path("app/shared/frontend/base.html").read_text()
search_sag_source = router_source.split("async def search_sag", 1)[1].split(
'@router.get("/live-stats"', 1
)[0]
assert "(s.deleted_at IS NOT NULL) AS is_archived" in search_sag_source
assert "WHERE s.deleted_at IS NULL" not in search_sag_source
assert "item.is_archived" in base_template
assert "Arkiveret" in base_template

View File

@ -127,6 +127,49 @@ def test_relation_quick_task_uses_todo_steps_api():
assert "due_date: due" in template
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "openForwardLinkedEmail('latest')" in template
assert "openForwardLinkedEmail('thread')" in template
assert "stripQuotedEmailHistory" in template
assert "caseEmailComposeMode = 'forward'" in template
assert "thread_email_id: isNewThread ? null" in template
assert "linkedEmailsCache\n .filter" in template
def test_case_email_snippet_removes_embedded_css_and_scripts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "function emailSnippetAsPlainText(email)" in template
assert "style, script, noscript, template, head" in template
assert "const snippet = emailSnippetAsPlainText(e).slice(0, 130);" in template
assert "snippetSource.replace(/<[^>]+>/g" not in template
def test_comment_composer_can_close_case_or_wait_for_customer():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert 'id="commentActionCloseCase"' in template
assert 'id="commentActionAwaitCustomer"' in template
assert "actions.push('close_case')" in template
assert "actions.push('await_customer')" in template
assert "await changeCaseStatusFromComment('lukket')" in template
assert "resolveCommentAwaitCustomerStatus()" in template
assert "awaitCustomerCheckbox.checked = false" in template
assert "closeCaseCheckbox.checked = false" in template
def test_comment_actions_use_styled_accessible_chips():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert 'class="comment-action-chips"' in template
assert template.count('class="comment-action-chip action-') == 5
assert '.comment-action-chip:has(input:checked)' in template
assert 'for="commentActionCloseCase"' in template
assert 'for="commentActionAwaitCustomer"' in template
def test_safe_case_html_renders_formatting_and_drops_executable_code():
result = sanitize_safe_html(
'<style>body{display:none}</style>'