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="card-body p-4">
<div class="text-center mb-4"> <div class="text-center mb-4">
<h2 class="fw-bold" style="color: var(--primary-color);">2FA Setup</h2> <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>
<div id="statusMessage" class="alert alert-info" role="alert"> <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>
<div class="d-grid gap-2 mb-3"> <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`; document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
if (data.requires_2fa_setup) { 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' : '/'; window.location.href = goSetup ? '/2fa/setup' : '/';
return; return;
} }

View File

@ -206,7 +206,7 @@
<ul class="nav nav-tabs mb-4" id="mainTabs"> <ul class="nav nav-tabs mb-4" id="mainTabs">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" id="unhandled-tab" data-bs-toggle="tab" href="#unhandled-content" onclick="switchToUnhandledTab()"> <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> <span class="badge bg-warning text-dark ms-2" id="unhandledCount" style="display: none;">0</span>
</a> </a>
</li> </li>
@ -241,7 +241,7 @@
<div class="alert alert-info mb-4"> <div class="alert alert-info mb-4">
<i class="bi bi-inbox me-2"></i> <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> </div>
<!-- Batch Actions Bar --> <!-- Batch Actions Bar -->
@ -349,7 +349,7 @@
<div class="alert alert-info mb-4"> <div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i> <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> </div>
<!-- Bulk Actions Bar for Payment --> <!-- Bulk Actions Bar for Payment -->
@ -559,7 +559,7 @@
</div> </div>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-success" onclick="bulkCreateInvoices()" title="Opret fakturaer"> <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>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="bulkReprocess()" title="Genbehandle filer"> <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 <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 fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field 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.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import ( from app.customers.backend.router import (
get_customer_subscriptions, get_customer_subscriptions,
@ -17,6 +24,7 @@ from app.customers.backend.router import (
SubscriptionComment, SubscriptionComment,
) )
import logging import logging
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -51,6 +59,26 @@ class ContactUpdate(BaseModel):
is_active: Optional[bool] = None 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): class ContactCompanyLink(BaseModel):
customer_id: int customer_id: int
is_primary: bool = True is_primary: bool = True
@ -290,6 +318,162 @@ async def create_contact(contact: ContactCreate):
raise HTTPException(status_code=500, detail=str(e)) 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}") @router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int): async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies""" """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 FROM sms_messages s
LEFT JOIN users u ON u.user_id = s.bruger_id LEFT JOIN users u ON u.user_id = s.bruger_id
WHERE s.kontakt_id = %s 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 ) z
ORDER BY z.happened_at DESC NULLS LAST ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s 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} return {"items": rows}
except HTTPException: except HTTPException:
raise raise

View File

@ -134,6 +134,9 @@
</div> </div>
</div> </div>
<div class="d-flex gap-2"> <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"> <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 <i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
</button> </button>
@ -705,6 +708,48 @@
</div> </div>
</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 --> <!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1"> <div class="modal fade" id="editContactModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
@ -802,11 +847,21 @@ let contactData = null;
let primaryCustomerId = null; let primaryCustomerId = null;
let kontaktHistoryItems = []; let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all'; let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadContact(); loadContact();
loadCompaniesForSelect(); 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 // Load companies when tab is shown
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => { document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
loadCompanies(); loadCompanies();
@ -1882,7 +1937,7 @@ function renderKontaktHistoryTable() {
if (!filteredItems.length) { if (!filteredItems.length) {
const msg = kontaktHistoryItems.length const msg = kontaktHistoryItems.length
? 'Ingen hændelser matcher filteret' ? '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>`; container.innerHTML = `<div class="text-muted text-center py-5">${msg}</div>`;
return; return;
} }
@ -1913,13 +1968,17 @@ function renderKontaktHistoryRow(item) {
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-'; const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
const typeBadge = item.type === 'sms' const typeBadge = item.type === 'sms'
? '<span class="badge bg-primary-subtle text-primary-emphasis">SMS</span>' ? '<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>'; : '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
const dirOrStatus = item.type === 'sms' const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-') ? (item.sms_status || '-')
: item.type === 'merge'
? 'Gennemført'
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående'); : (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
const message = item.type === 'sms' const message = item.type === 'sms' || item.type === 'merge'
? escapeHtml(item.message || '-') ? 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) { function getInitials(firstName, lastName) {
if (!firstName && !lastName) return '?'; if (!firstName && !lastName) return '?';
const first = firstName ? firstName[0] : ''; const first = firstName ? firstName[0] : '';

View File

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

View File

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

View File

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

View File

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

View File

@ -447,7 +447,7 @@
const ids = selectedIds(); const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry'); 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; if (!ok) return;
try { try {
@ -462,7 +462,7 @@
const skipped = (result.skipped_missing_customer || []); const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []); const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet'; 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 skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}` ? `\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 logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional 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] = { payload: Dict[str, Any] = {
"context_key": "global", "context_key": "global",
"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": "new_mail", "label": "Ny mail", "action": "/emails"},
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"}, {"id": "start_timer", "label": "Skift/start timer", "command": "switch_timer", "icon": "bi-stopwatch"},
{"id": "log_time", "label": "Log tid", "action": "/timetracking"}, {"id": "log_time", "label": "Tidsoversigt", "action": "/timetracking/registrations", "icon": "bi-clock-history"},
{"id": "add_note", "label": "Tilføj note", "action": "/sag"}, {"id": "add_note", "label": "Ny personlig note", "command": "open_notes", "icon": "bi-journal-plus"},
], ],
"context": [], "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["context_key"] = "sag"
payload["case_id"] = case_id
payload["context"] = [ payload["context"] = [
{"id": "case_time", "label": "Tid", "action": "/timetracking"}, {"id": "case_time", "label": "Registrér tid", "command": "case_add:time", "icon": "bi-clock"},
{"id": "case_mail", "label": "Mail", "action": "/emails"}, {"id": "case_note", "label": "Tilføj kommentar", "command": "case_add:note", "icon": "bi-chat-left-text"},
{"id": "case_relation", "label": "Relation", "action": "/customers"}, {"id": "case_mail", "label": "Send mail", "command": "case_add:email", "icon": "bi-envelope"},
{"id": "case_tag", "label": "Tag", "action": "/tags"}, {"id": "case_subscription", "label": "Abonnement", "command": "case_add:subscription", "icon": "bi-arrow-repeat"},
] ]
elif normalized.startswith("/hardware"): elif normalized.startswith("/hardware"):
payload["context_key"] = "hardware" payload["context_key"] = "hardware"

View File

@ -502,12 +502,12 @@
<div class="calendar-shell fade-up"> <div class="calendar-shell fade-up">
<div class="calendar-toolbar"> <div class="calendar-toolbar">
<div class="view-buttons" id="viewButtons"> <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="timeGridWeek">Uge</button>
<button type="button" data-view="timeGridDay">Dag</button> <button type="button" data-view="timeGridDay">Dag</button>
<button type="button" data-view="listWeek">Agenda</button> <button type="button" data-view="listWeek">Agenda</button>
</div> </div>
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div> <div class="status-bar" id="rangeLabel">Indlæser periode...</div>
</div> </div>
<div id="calendar"></div> <div id="calendar"></div>
<div class="calendar-legend"> <div class="calendar-legend">

View File

@ -883,7 +883,7 @@
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-6 col-xl-3"> <div class="col-md-6 col-xl-3">
<div class="stat-tile"> <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-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 class="stat-helper">Fra alle ordrelinjer pa dette asset</div>
</div> </div>
@ -958,7 +958,7 @@
<th>Dato</th> <th>Dato</th>
<th>Titel</th> <th>Titel</th>
<th>Status</th> <th>Status</th>
<th class="text-end">Beloeb</th> <th class="text-end">Beløb</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -1054,13 +1054,13 @@
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow. Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
</div> </div>
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3"> <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>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Kunde</label> <label class="form-label">Kunde</label>
<select class="form-select" id="quickRentCustomerId" required> <select class="form-select" id="quickRentCustomerId" required>
<option value="">-- Vaelg kunde --</option> <option value="">-- Vælg kunde --</option>
{% for customer in owner_customers %} {% for customer in owner_customers %}
<option value="{{ customer.id }}">{{ customer.navn }}</option> <option value="{{ customer.id }}">{{ customer.navn }}</option>
{% endfor %} {% endfor %}
@ -1480,11 +1480,11 @@
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2); const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) { if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er paakraevet.'); alert('Kunde, sag og startdato er påkrævet.');
return; return;
} }
if (operationsMonthlyPrice <= 0) { if (operationsMonthlyPrice <= 0) {
alert('Drift pr. maned skal vaere over 0.'); alert('Drift pr. måned skal være over 0.');
return; return;
} }
@ -1548,14 +1548,14 @@
if (!customerId || !sagId) { if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3'; 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; submitBtn.disabled = false;
return; return;
} }
try { try {
infoEl.className = 'alert alert-secondary py-2 small mb-3'; 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 response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json(); const data = await response.json();
@ -1578,7 +1578,7 @@
} catch (error) { } catch (error) {
submitBtn.disabled = false; submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3'; 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="section-card" id="devicesSection">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div> <div class="status-pill" id="deviceStatus">Ingen data indlæst</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="runOnePcFullTest()">Test 1 PC (ALT)</button> <button class="btn btn-outline-primary" onclick="runOnePcFullTest()">Test 1 PC (ALT)</button>
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button> <button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
@ -219,7 +219,7 @@
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button> <button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
</div> </div>
<div id="contactResults" class="contact-results"></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>
<div class="mb-3"> <div class="mb-3">
@ -365,7 +365,7 @@
<div class="device-card-meta">Gruppe: ${safeGroup}</div> <div class="device-card-meta">Gruppe: ${safeGroup}</div>
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div> <div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
<div class="mt-3"> <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"> <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="inlineResults-${index}" class="inline-results"></div>
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div> <div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
@ -409,7 +409,7 @@
async function searchInlineContacts(query, index) { async function searchInlineContacts(query, index) {
const results = document.getElementById(`inlineResults-${index}`); const results = document.getElementById(`inlineResults-${index}`);
if (!results) return; 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 { try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`); const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
@ -571,10 +571,10 @@
const query = document.getElementById('contactSearch').value.trim(); const query = document.getElementById('contactSearch').value.trim();
const results = document.getElementById('contactResults'); const results = document.getElementById('contactResults');
if (!query) { 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; return;
} }
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>'; results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
try { try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`); const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
if (!response.ok) { if (!response.ok) {

View File

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

View File

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

View File

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

View File

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

View File

@ -190,7 +190,7 @@ let currentCardStatus = '';
const DEFAULT_TIME_MULTIPLIER_PRESETS = [ const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 }, { 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 } { label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
]; ];

View File

@ -107,7 +107,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Note</label> <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>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Opdateret af</label> <label class="form-label">Opdateret af</label>
@ -117,24 +117,24 @@
<div class="small mt-3" id="priceUpdateMessage"></div> <div class="small mt-3" id="priceUpdateMessage"></div>
</div> </div>
<div class="product-card mt-3"> <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"> <div class="mb-3">
<label class="form-label">Leverandoer</label> <label class="form-label">Leverandør</label>
<input type="text" class="form-control" id="supplierName" placeholder="Leverandoer navn"> <input type="text" class="form-control" id="supplierName" placeholder="Leverandørnavn">
</div> </div>
<div class="mb-3"> <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"> <input type="number" class="form-control" id="supplierPrice" step="0.01" min="0">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Note</label> <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>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Opdateret af</label> <label class="form-label">Opdateret af</label>
<input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn"> <input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn">
</div> </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 class="small mt-3" id="supplierUpdateMessage"></div>
</div> </div>
</div> </div>
@ -144,7 +144,7 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm product-table mb-0"> <table class="table table-sm product-table mb-0">
<tbody id="productInfoBody"> <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> </tbody>
</table> </table>
</div> </div>
@ -164,7 +164,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="priceHistoryBody"> <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> </tbody>
</table> </table>
</div> </div>
@ -186,7 +186,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="salesHistoryBody"> <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> </tbody>
</table> </table>
</div> </div>
@ -218,7 +218,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="supplierListBody"> <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> </tbody>
</table> </table>
</div> </div>
@ -256,7 +256,7 @@
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK"> <input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
</div> </div>
<div class="col-md-3"> <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>
<div class="col-12"> <div class="col-12">
<div class="small" id="supplierListMessage"></div> <div class="small" id="supplierListMessage"></div>
@ -356,9 +356,9 @@ function renderProductInfo(product) {
['Status', product.status], ['Status', product.status],
['SKU intern', product.sku_internal], ['SKU intern', product.sku_internal],
['Producent', product.manufacturer], ['Producent', product.manufacturer],
['Leverandoer', product.supplier_name], ['Leverandør', product.supplier_name],
['Leverandoer SKU', product.supplier_sku], ['Leverandør-SKU', product.supplier_sku],
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null], ['Leverandørpris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null], ['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null], ['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : 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('productPrice').textContent = product.sales_price != null ? formatCurrency(product.sales_price) : '-';
document.getElementById('productSku').textContent = product.sku_internal || '-'; document.getElementById('productSku').textContent = product.sku_internal || '-';
document.getElementById('productSupplierPrice').textContent = product.supplier_price != null document.getElementById('productSupplierPrice').textContent = product.supplier_price != null
? `Leverandoer: ${formatCurrency(product.supplier_price)}` ? `Leverandør: ${formatCurrency(product.supplier_price)}`
: 'Leverandoer pris: -'; : 'Leverandørpris: -';
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : ''; document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
document.getElementById('supplierName').value = product.supplier_name || ''; document.getElementById('supplierName').value = product.supplier_name || '';
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : ''; document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
@ -409,7 +409,7 @@ async function loadProductDetail() {
renderProductInfo(product); renderProductInfo(product);
prefillSupplierSku(product); prefillSupplierSku(product);
} catch (e) { } 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 => ` tbody.innerHTML = history.map(entry => `
<tr> <tr>
<td>${formatDate(entry.changed_at)}</td> <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.old_price != null ? formatCurrency(entry.old_price) : '-'}</td>
<td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td> <td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td>
<td>${escapeHtml(entry.note || '-')}</td> <td>${escapeHtml(entry.note || '-')}</td>
@ -693,10 +693,10 @@ async function submitSupplierUpdate() {
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json(); 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(); await res.json();
setSupplierMessage('Leverandoer opdateret', 'text-success'); setSupplierMessage('Leverandør opdateret', 'text-success');
await loadProductDetail(); await loadProductDetail();
await loadPriceHistory(); await loadPriceHistory();
} catch (e) { } catch (e) {

View File

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

View File

@ -228,7 +228,7 @@
lastError = errMsg; lastError = errMsg;
} catch (err) { } catch (err) {
attempts.push(`${url} -> ERR`); attempts.push(`${url} -> ERR`);
lastError = err.message || 'Netvaerksfejl'; lastError = err.message || 'Netværksfejl';
} }
} }
@ -335,7 +335,7 @@
lastError = errMsg; lastError = errMsg;
} catch (err) { } catch (err) {
attempts.push(`${url} -> 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()"> <select class="form-select" id="taskTemplateSourceFilter" onchange="loadTaskTemplates()">
<option value="all">Alle</option> <option value="all">Alle</option>
<option value="company">Firma</option> <option value="company">Firma</option>
<option value="global">Faelles</option> <option value="global">Fælles</option>
<option value="internal">Intern</option> <option value="internal">Intern</option>
</select> </select>
</div> </div>
@ -647,7 +647,7 @@
<option value="offboarding">Offboarding</option> <option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option> <option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option> <option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option> <option value="brugerandring">Brugerændring</option>
<option value="andet">Andet</option> <option value="andet">Andet</option>
</select> </select>
</div> </div>
@ -692,11 +692,11 @@
<div class="card-header bg-white d-flex justify-content-between align-items-center"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="fw-bold mb-0" id="taskTemplateItemsTitle">Template-opgaver</h6> <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>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="taskTemplateAddItemBtn" onclick="openTaskTemplateItemModal()" disabled> <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>
<button class="btn btn-sm btn-outline-secondary" id="taskTemplateBulkBuilderBtn" onclick="openTaskTemplateBulkBuilderModal()" disabled> <button class="btn btn-sm btn-outline-secondary" id="taskTemplateBulkBuilderBtn" onclick="openTaskTemplateBulkBuilderModal()" disabled>
<i class="bi bi-magic me-1"></i>Builder <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 class="card-header bg-white d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6> <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>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span> <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"> <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">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> </div>
</div> </div>
@ -2114,7 +2114,7 @@ let sagTestReportsCache = [];
const DEFAULT_TIME_MULTIPLIER_PRESETS = [ const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 }, { 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 } { 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> <label class="form-label fw-semibold">Ignorer varetekster</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off"> <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>
<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 id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
</div> </div>
</div> </div>
@ -2648,7 +2648,7 @@ async function loadSettings() {
try { try {
const response = await fetch('/api/v1/settings', { credentials: 'include' }); const response = await fetch('/api/v1/settings', { credentials: 'include' });
if (!response.ok) { 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(); const payload = await response.json();
allSettings = Array.isArray(payload) ? payload : []; allSettings = Array.isArray(payload) ? payload : [];
@ -2744,11 +2744,11 @@ function addTimeMultiplierPreset() {
const multiplier = Number(multiplierInput.value || 0); const multiplier = Number(multiplierInput.value || 0);
if (!label) { if (!label) {
showNotification('Navn er paakraevet', 'error'); showNotification('Navn er påkrævet', 'error');
return; return;
} }
if (!Number.isFinite(multiplier) || multiplier <= 0) { 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; return;
} }
@ -4111,7 +4111,7 @@ async function loadAdminUsers() {
try { try {
const response = await fetch('/api/v1/admin/users'); const response = await fetch('/api/v1/admin/users');
if (!response.ok) { 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(); usersCache = await response.json();
displayUsers(usersCache); displayUsers(usersCache);
@ -4119,7 +4119,7 @@ async function loadAdminUsers() {
} catch (error) { } catch (error) {
console.error('Error loading users:', error); console.error('Error loading users:', error);
const tbody = document.getElementById('usersTableBody'); 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 { try {
const response = await fetch('/api/v1/pipeline/stages'); const response = await fetch('/api/v1/pipeline/stages');
if (!response.ok) { 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 payload = await response.json();
const stages = Array.isArray(payload) ? payload : []; const stages = Array.isArray(payload) ? payload : [];
@ -6698,14 +6698,14 @@ document.addEventListener('DOMContentLoaded', () => {
<option value="offboarding">Offboarding</option> <option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option> <option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option> <option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option> <option value="brugerandring">Brugerændring</option>
<option value="andet" selected>Andet</option> <option value="andet" selected>Andet</option>
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Firma (kun firma-type)</label> <label class="form-label">Firma (kun firma-type)</label>
<select id="taskTemplateCompany" class="form-select"> <select id="taskTemplateCompany" class="form-select">
<option value="">Vaelg firma...</option> <option value="">Vælg firma...</option>
</select> </select>
</div> </div>
</div> </div>
@ -6728,7 +6728,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -6849,21 +6849,21 @@ const TASK_TEMPLATE_BUILDER_PRESETS = {
onboarding: [ onboarding: [
'task|0|Velkomstmail til bruger|Send velkomstmail med kontaktinfo og forventet plan', 'task|0|Velkomstmail til bruger|Send velkomstmail med kontaktinfo og forventet plan',
'task|0|Bestil hardware|Bestil standard udstyr til ny medarbejder', 'task|0|Bestil hardware|Bestil standard udstyr til ny medarbejder',
'task|1|Klargoer kontoer og licenser|Opsaet M365, VPN, sikkerhedsprofil og grupper', 'task|1|Klargør konti og licenser|Opsæt M365, VPN, sikkerhedsprofil og grupper',
'task|2|Planlaeg introduktionsmoede|Book intro med leder og support', 'task|2|Planlæg introduktionsmøde|Book introduktion med leder og support',
'subcase|0|Onboarding undersag: adgange|Opfoelgning paa adgangsrequests og godkendelser' 'subcase|0|Onboarding-undersag: adgange|Opfølgning på adgangsanmodninger og godkendelser'
], ],
offboarding: [ 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|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' 'subcase|0|Offboarding undersag: dokumentation|Saml audit-noter og afslutningsdokumentation'
], ],
hardware: [ hardware: [
'task|0|Afdaek behov|Afklar krav til model, tilbehoer og levering', 'task|0|Afdæk behov|Afklar krav til model, tilbehør og levering',
'task|0|Indhent pris|Kontroller pris og leveringstid hos leverandoer', 'task|0|Indhent pris|Kontrollér pris og leveringstid hos leverandør',
'task|1|Bestil hardware|Placer ordren og registrer ordrenummer', '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() { function openTaskTemplateBulkBuilderModal() {
if (!selectedTaskTemplateId) { if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst'); alert('Vælg en template først');
return; return;
} }
@ -6964,7 +6964,7 @@ function parseTaskTemplateBuilderLines(rawText, defaults = {}) {
async function importTaskTemplateBulkItems() { async function importTaskTemplateBulkItems() {
if (!selectedTaskTemplateId) { if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst'); alert('Vælg en template først');
return; return;
} }
@ -7040,7 +7040,7 @@ function taskTemplateCategoryLabel(category) {
offboarding: 'Offboarding', offboarding: 'Offboarding',
simkort: 'Mobil / SIM-kort', simkort: 'Mobil / SIM-kort',
hardwarebestilling: 'Hardwarebestilling', hardwarebestilling: 'Hardwarebestilling',
brugerandring: 'Brugeraendring', brugerandring: 'Brugerændring',
andet: 'Andet' andet: 'Andet'
}; };
return labels[category] || category || '-'; return labels[category] || category || '-';
@ -7063,7 +7063,7 @@ async function loadTaskTemplateCustomers() {
if (!filterSelect || !modalSelect) return; if (!filterSelect || !modalSelect) return;
filterSelect.innerHTML = '<option value="">Alle kunder</option>'; 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) => { customers.forEach((customer) => {
const label = `${customer.name} (#${customer.id})`; const label = `${customer.name} (#${customer.id})`;
@ -7126,7 +7126,7 @@ async function loadTaskTemplates() {
`).join(''); `).join('');
} catch (error) { } catch (error) {
console.error('Error loading task templates:', 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) { if (!payload.name) {
alert('Navn er paakraevet'); alert('Navn er påkrævet');
return; return;
} }
if (templateType === 'company' && !payload.customer_id) { if (templateType === 'company' && !payload.customer_id) {
alert('Vaelg firma for firma-template'); alert('Vælg firma til firmatemplate');
return; return;
} }
@ -7215,7 +7215,7 @@ async function deactivateTaskTemplateSettings(templateId) {
if (selectedTaskTemplateId === templateId) { if (selectedTaskTemplateId === templateId) {
selectedTaskTemplateId = null; selectedTaskTemplateId = null;
document.getElementById('taskTemplateItemsTitle').textContent = 'Template-opgaver'; 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('taskTemplateItemsList').innerHTML = '<div class="list-group-item text-muted small">Ingen template valgt.</div>';
document.getElementById('taskTemplateAddItemBtn').disabled = true; document.getElementById('taskTemplateAddItemBtn').disabled = true;
document.getElementById('taskTemplateBulkBuilderBtn').disabled = true; document.getElementById('taskTemplateBulkBuilderBtn').disabled = true;
@ -7295,13 +7295,13 @@ async function loadTaskTemplateItems(templateId) {
function openTaskTemplateItemModal() { function openTaskTemplateItemModal() {
if (!selectedTaskTemplateId) { if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst'); alert('Vælg en template først');
return; return;
} }
document.getElementById('taskTemplateItemForm').reset(); document.getElementById('taskTemplateItemForm').reset();
document.getElementById('taskTemplateItemId').value = ''; document.getElementById('taskTemplateItemId').value = '';
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilfoej item'; document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilføj element';
document.getElementById('taskTemplateItemRequired').checked = true; document.getElementById('taskTemplateItemRequired').checked = true;
document.getElementById('taskTemplateItemActive').checked = true; document.getElementById('taskTemplateItemActive').checked = true;
@ -7329,7 +7329,7 @@ function editTaskTemplateItem(item) {
async function saveTaskTemplateItem() { async function saveTaskTemplateItem() {
if (!selectedTaskTemplateId) { if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst'); alert('Vælg en template først');
return; return;
} }
@ -7348,7 +7348,7 @@ async function saveTaskTemplateItem() {
}; };
if (!payload.title) { if (!payload.title) {
alert('Titel er paakraevet'); alert('Titel er påkrævet');
return; return;
} }

View File

@ -22,7 +22,7 @@
--frame-shadow: 0 4px 12px rgba(15, 76, 117, 0.10); --frame-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
--border-radius: 12px; --border-radius: 12px;
--bottom-bar-height: 50px; --bottom-bar-height: 50px;
--bottom-bar-expanded-height: 50vh; --bottom-bar-expanded-height: 68vh;
--bottom-bar-zindex: 1030; --bottom-bar-zindex: 1030;
} }
@ -53,7 +53,7 @@
} }
body.bottom-bar-visible.bottom-bar-expanded { 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 { .global-bottom-bar {
@ -381,7 +381,7 @@
} }
.global-bottom-bar.is-expanded .bb-sheet-panel { .global-bottom-bar.is-expanded .bb-sheet-panel {
max-height: min(52vh, 460px); max-height: min(var(--bottom-bar-expanded-height), 590px);
opacity: 1; opacity: 1;
} }
@ -390,10 +390,10 @@
border: 1px solid rgba(var(--text-primary-rgb), 0.1); border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 16px; border-radius: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
min-height: 240px; min-height: 320px;
max-height: min(52vh, 420px); max-height: min(var(--bottom-bar-expanded-height), 540px);
height: min(52vh, 420px); height: min(var(--bottom-bar-expanded-height), 540px);
overflow: hidden; overflow: hidden;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
margin-top: 0.5rem; margin-top: 0.5rem;
@ -403,15 +403,18 @@
} }
.global-bottom-bar .bb-side-tabs { .global-bottom-bar .bb-side-tabs {
border-right: 0; width: 176px;
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.09); flex: 0 0 176px;
background: rgba(var(--text-primary-rgb), 0.025); border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
padding: 0.55rem 0.65rem; border-bottom: 0;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
padding: 0.85rem 0.65rem;
display: flex; display: flex;
gap: 0.35rem; flex-direction: column;
align-items: center; gap: 0.3rem;
min-height: auto; align-items: stretch;
overflow-x: auto; min-height: 0;
overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
} }
.global-bottom-bar .bb-side-tabs::-webkit-scrollbar { display: none; } .global-bottom-bar .bb-side-tabs::-webkit-scrollbar { display: none; }
@ -421,10 +424,10 @@
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
border-radius: 9px; border-radius: 9px;
text-align: center; text-align: left;
font-size: 0.8rem; font-size: 0.84rem;
font-weight: 600; font-weight: 600;
padding: 0.45rem 0.7rem; padding: 0.62rem 0.7rem;
line-height: 1.3; line-height: 1.3;
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex; display: flex;
@ -432,6 +435,7 @@
gap: 0.5rem; gap: 0.5rem;
white-space: nowrap; white-space: nowrap;
flex: 0 0 auto; flex: 0 0 auto;
width: 100%;
} }
.global-bottom-bar .bb-tab-btn i { .global-bottom-bar .bb-tab-btn i {
font-size: 1rem; font-size: 1rem;
@ -475,48 +479,58 @@
} }
.global-bottom-bar .bb-tab-content { .global-bottom-bar .bb-tab-content {
padding: 0.85rem 1rem 1rem; padding: 1rem 1.15rem 1.1rem;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 0;
width: 100%;
min-width: 0;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.global-bottom-bar .bb-tab-title { .global-bottom-bar .bb-tab-title {
font-size: 1rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.15rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
line-height: 1.15; 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 { .global-bottom-bar .bb-tab-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: grid; display: grid;
gap: 0.6rem; gap: 0.45rem;
} }
.global-bottom-bar .bb-tab-list li { .global-bottom-bar .bb-tab-list li {
border-left: 4px solid var(--accent); border: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: var(--accent-light); background: color-mix(in srgb, var(--bg-card) 96%, var(--text-primary));
border-radius: 6px 8px 8px 6px; border-radius: 11px;
padding: 0.65rem 0.85rem; padding: 0.68rem 0.78rem;
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); 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; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.global-bottom-bar .bb-tab-list li:hover { .global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px); transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08); 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 { .global-bottom-bar .bb-boss-hero {
display: flex; display: flex;
align-items: center; align-items: center;
@ -535,7 +549,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.6rem;
padding: 0.65rem 0.7rem; padding: 0.65rem 0.7rem;
border: 1px solid rgba(245, 158, 11, 0.28); border: 1px solid rgba(245, 158, 11, 0.28);
border-left: 4px solid #f59e0b; border-left: 4px solid #f59e0b;
@ -558,7 +572,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.1rem; padding: 0.1rem 0 0.45rem;
scrollbar-width: none; scrollbar-width: none;
} }
.global-bottom-bar .bb-message-threads::-webkit-scrollbar { .global-bottom-bar .bb-message-threads::-webkit-scrollbar {
@ -600,7 +614,8 @@
color: #fff; color: #fff;
} }
.global-bottom-bar .bb-messages-list { .global-bottom-bar .bb-messages-list {
max-height: min(26vh, 240px); max-height: none;
flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
padding-right: 0.2rem; padding-right: 0.2rem;
} }
@ -608,10 +623,16 @@
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; 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 { .global-bottom-bar .bb-notes-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -621,23 +642,44 @@
overflow: hidden; overflow: hidden;
} }
.global-bottom-bar .bb-notes-editor { .global-bottom-bar .bb-notes-editor {
flex: 0 0 auto; min-width: 0;
} }
.global-bottom-bar .bb-notes-list { .global-bottom-bar .bb-notes-list {
flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-right: 0.2rem; padding-right: 0.2rem;
} }
.global-bottom-bar .bb-messages-composer { .global-bottom-bar .bb-messages-composer {
border-top: 1px solid rgba(var(--text-primary-rgb), 0.08); border: 1px solid rgba(var(--text-primary-rgb), 0.08);
padding-top: 0.55rem; background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
border-radius: 12px;
padding: 0.65rem;
margin-top: 0.15rem; margin-top: 0.15rem;
} }
.global-bottom-bar .bb-messages-layout { .global-bottom-bar .bb-messages-layout {
height: 100%; height: 100%;
overflow: hidden; 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 { .global-bottom-bar .bb-detail-line {
min-height: 28px; min-height: 28px;
padding: 0.2rem 0.2rem 0; padding: 0.2rem 0.2rem 0;
@ -659,6 +701,34 @@
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08); 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 { #bbSwitchCaseModal .list-group-item {
border-color: rgba(var(--text-primary-rgb), 0.08); border-color: rgba(var(--text-primary-rgb), 0.08);
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@ -690,6 +760,7 @@
.global-bottom-bar .bb-sheet-inner { .global-bottom-bar .bb-sheet-inner {
min-height: 240px; min-height: 240px;
flex-direction: column;
} }
.global-bottom-bar.is-expanded .bb-header { .global-bottom-bar.is-expanded .bb-header {
@ -705,13 +776,22 @@
} }
.global-bottom-bar .bb-side-tabs { .global-bottom-bar .bb-side-tabs {
width: 100%;
flex: 0 0 auto;
flex-direction: row;
border-right: none; border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: 1px solid rgba(0, 0, 0, 0.08);
overflow-x: auto; 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-card { align-items: stretch; flex-direction: column; }
.global-bottom-bar .bb-support-queue-actions { width: 100%; } .global-bottom-bar .bb-support-queue-actions { width: 100%; }
.global-bottom-bar .bb-support-queue-actions select { width: auto; flex: 1 1 auto; } .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 { .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> <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>
<div class="bb-tab-content" role="tabpanel" aria-live="polite"> <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="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"> <div id="bbTabInnerContent">
<ul id="bbTabList" class="bb-tab-list"> <ul id="bbTabList" class="bb-tab-list">
<li>Venter på data...</li> <li>Venter på data...</li>
@ -1386,14 +1469,17 @@
</div> </div>
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel"> <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-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div> </div>
<div class="modal-body"> <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 id="bbSwitchTimerActions" class="mb-3 d-none">
<div class="fw-semibold mb-2">Aktiv timer fundet</div> <div class="fw-semibold mb-2">Aktiv timer fundet</div>
@ -1404,20 +1490,23 @@
</div> </div>
</div> </div>
<div class="row g-3"> <div id="bbConvertTimePanel" class="bb-convert-panel d-none mb-3">
<div class="col-12 col-lg-6"> <div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<h6 class="mb-2">Dine aktive/pausede timere</h6> <div><div class="fw-bold">Konverter afsluttet tid</div><div id="bbConvertTimeName" class="small text-muted"></div></div>
<div id="bbSwitchTimersList" class="list-group small"> <button type="button" class="btn-close" data-bb-cancel-convert aria-label="Luk"></button>
<div class="list-group-item text-muted">Henter timere...</div> </div>
</div> <div class="row g-2">
</div> <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-12 col-lg-6"> <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>
<h6 class="mb-2">Seneste sager</h6> <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 id="bbSwitchRecentCasesList" class="list-group small"> <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 class="list-group-item text-muted">Henter sager...</div>
</div>
</div> </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> </div>
</div> </div>
@ -1489,7 +1578,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></script> <script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.1"></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/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> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true; window.BMC_CAN_CLICK_TO_CALL = true;
@ -2079,6 +2168,9 @@ if (bmcOriginalFetch) {
const buzzwordHtml = buzzwords.length const buzzwordHtml = buzzwords.length
? ` • <i class="bi bi-lightbulb ms-1"></i> ${buzzwords.map(word => escapeHtml(word)).join(', ')}` ? ` • <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 ` return `
<div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;"> <div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;">
<div> <div>
@ -2087,6 +2179,7 @@ if (bmcOriginalFetch) {
<i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')} <i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')}
${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''} ${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
${buzzwordHtml} ${buzzwordHtml}
${archivedHtml}
</div> </div>
</div> </div>
<i class="bi bi-arrow-right"></i> <i class="bi bi-arrow-right"></i>

View File

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

View File

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

View File

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

View File

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

View File

@ -116,7 +116,7 @@ class OrderService:
WHERE t.customer_id = %s WHERE t.customer_id = %s
AND t.status = 'approved' AND t.status = 'approved'
AND t.billable = true 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 ORDER BY COALESCE(c.id, s.id), t.worked_date
""" """
approved_times = execute_query(query, (customer_id,)) 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]: 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).""" """Resolve tmodule customer_id for a case (tmodule_times FK target)."""
try: try:
@ -1811,7 +1853,10 @@ async def list_time_entries(
status: Optional[str] = None, status: Optional[str] = None,
customer_id: Optional[int] = None, customer_id: Optional[int] = None,
user_name: Optional[str] = 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. Hent liste af tidsregistreringer med filtre.
@ -1841,6 +1886,18 @@ async def list_time_entries(
query += " AND t.user_name ILIKE %s" query += " AND t.user_name ILIKE %s"
params.append(f"%{user_name}%") 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: if search:
query += """ AND ( query += """ AND (
t.description ILIKE %s OR t.description ILIKE %s OR
@ -1850,11 +1907,36 @@ async def list_time_entries(
wildcard = f"%{search}%" wildcard = f"%{search}%"
params.extend([wildcard, wildcard, wildcard]) 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" 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)) 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: except Exception as e:
logger.error(f"Error listing times: {e}") logger.error(f"Error listing times: {e}")
raise HTTPException(status_code=500, detail=str(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), sag_id: int = Query(..., gt=0),
day: Optional[date] = Query(None), day: Optional[date] = Query(None),
medarbejder_id: Optional[int] = Query(None, gt=0), 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.""" """List tidsregistreringer for en sag med filtre til timeline UI."""
try: try:
@ -2255,13 +2338,31 @@ async def list_time_entries_v1(
where_sql = " AND ".join(clauses) where_sql = " AND ".join(clauses)
query = f""" 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 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 LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE {where_sql} WHERE {where_sql}
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC 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: except Exception as e:
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, 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") raise HTTPException(status_code=500, detail="Failed to list time entries")
@ -2303,35 +2404,14 @@ async def start_live_timer_v1(
paused_entry = None paused_entry = None
if existing: 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( execute_update(
""" """
UPDATE tmodule_times UPDATE tmodule_times
SET slut_tid = %s, SET aktiv_timer = FALSE,
aktiv_timer = FALSE, paused_at = %s
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'
WHERE id = %s WHERE id = %s
""", """,
( (now, existing["id"])
now,
pause_total_seconds,
actual_minutes,
rounded_minutes,
actual_minutes,
rounded_minutes,
existing.get("round_block_min") or 30,
existing["id"],
)
) )
paused_entry = existing["id"] paused_entry = existing["id"]
@ -2353,13 +2433,13 @@ async def start_live_timer_v1(
worked_date, user_name, status, billable, worked_date, user_name, status, billable,
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min, start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id, entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret aktiv_timer, round_block_min, ikke_placeret, work_type
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s %s, %s, %s, %s
) RETURNING * ) RETURNING *
""", """,
( (
@ -2382,6 +2462,7 @@ async def start_live_timer_v1(
True, True,
round_block_min, round_block_min,
False, False,
payload.get("work_type") or "support",
) )
) )
@ -2615,7 +2696,7 @@ async def resume_live_timer_v1(
async def list_my_switchable_timers_v1( async def list_my_switchable_timers_v1(
current_user: Optional[dict] = Depends(get_optional_user) 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: try:
bruger_id = _resolve_current_user_id(current_user) bruger_id = _resolve_current_user_id(current_user)
if not bruger_id: if not bruger_id:
@ -2623,8 +2704,24 @@ async def list_my_switchable_timers_v1(
active = execute_query( 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 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 LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL AND t.slut_tid IS NULL
@ -2637,8 +2734,13 @@ async def list_my_switchable_timers_v1(
paused = execute_query( 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 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 LEFT JOIN users u ON u.user_id = t.medarbejder_id
WHERE t.medarbejder_id = %s WHERE t.medarbejder_id = %s
AND t.slut_tid IS NULL AND t.slut_tid IS NULL
@ -2649,9 +2751,38 @@ async def list_my_switchable_timers_v1(
(bruger_id,), (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 { return {
"active": active or [], "active": active or [],
"paused": paused or [], "paused": paused or [],
"stopped": stopped or [],
} }
except HTTPException: except HTTPException:
raise raise
@ -2660,6 +2791,47 @@ async def list_my_switchable_timers_v1(
raise HTTPException(status_code=500, detail="Failed to list switchable timers") 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"]) @router.post("/time/manual", tags=["Internal"])
async def create_manual_time_v1( async def create_manual_time_v1(
payload: Dict[str, Any] = Body(...), 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, start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
entry_type, kilde, entry_status, medarbejder_id, entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret, aktiv_timer, round_block_min, ikke_placeret,
approved_hours, rounded_to approved_hours, rounded_to, work_type
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %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,
%s, %s, %s, %s, %s, %s,
%s, %s %s, %s, %s
) RETURNING * ) RETURNING *
""" """
@ -2761,6 +2933,7 @@ async def create_manual_time_v1(
not_placed, not_placed,
(billable_minutes / 60.0) if billable else None, (billable_minutes / 60.0) if billable else None,
(round_block_min / 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 return inserted[0] if inserted else None
@ -2787,7 +2960,7 @@ async def patch_time_entry_v1(
updates: Dict[str, Any] = {} updates: Dict[str, Any] = {}
allowed_direct = [ allowed_direct = [
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date", "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: for key in allowed_direct:
if key in payload: 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") 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"]) @router.post("/time/{time_id}/approve", tags=["Internal"])
async def approve_time_entry_v1( async def approve_time_entry_v1(
time_id: int, time_id: int,
@ -2865,13 +3047,26 @@ async def approve_time_entry_v1(
_assert_prepaid_entry_editable(entry) _assert_prepaid_entry_editable(entry)
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt" 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")) is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if entry_type == "ukendt": if entry_type == "ukendt":
if not is_admin_approver: if not is_admin_approver:
raise HTTPException(status_code=400, detail="entry_type is required before approval") 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) 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") billed_minutes = payload.get("fakturerbar_tid_min")
if billed_minutes is None: if billed_minutes is None:
billed_minutes = entry.get("fakturerbar_tid_min") 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 = _round_up_minutes(faktisk, int(entry.get("round_block_min") or 30))
billed_minutes = int(billed_minutes) 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) approved_by = _resolve_current_user_id(current_user)
updated = execute_query( updated = execute_query(
""" """
UPDATE tmodule_times UPDATE tmodule_times
SET entry_type = %s, SET entry_type = %s,
work_type = %s,
entry_status = 'godkendt', entry_status = 'godkendt',
status = 'approved', status = %s,
billable = %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, 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_at = %s,
approved_by = %s, approved_by = %s,
aktiv_timer = FALSE aktiv_timer = FALSE
@ -2898,11 +3125,17 @@ async def approve_time_entry_v1(
""", """,
( (
entry_type, entry_type,
work_type,
final_status,
billable, billable,
billing_method,
int(prepaid_card_id) if billing_method == "prepaid" else None,
int(agreement_id) if billing_method == "subscription" else None,
billable, billable,
billed_minutes, billed_minutes,
billable, billable,
billed_minutes, billed_minutes,
billed_minutes,
datetime.now(), datetime.now(),
approved_by, approved_by,
time_id, time_id,

View File

@ -114,7 +114,7 @@
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="log-hero"> <div class="log-hero">
<h1 class="h4 mb-1">Medarbejder Log</h1> <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>
<div class="log-card"> <div class="log-card">
@ -124,7 +124,7 @@
<select id="granularity" class="form-select"> <select id="granularity" class="form-select">
<option value="day">Dag</option> <option value="day">Dag</option>
<option value="week" selected>Uge</option> <option value="week" selected>Uge</option>
<option value="month">Maaned</option> <option value="month">Måned</option>
</select> </select>
</div> </div>
<div> <div>
@ -136,7 +136,7 @@
<input id="endDate" type="date" class="form-control"> <input id="endDate" type="date" class="form-control">
</div> </div>
<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"> <input id="targetHours" type="number" min="0" max="24" step="0.5" value="7.5" class="form-control">
</div> </div>
<div> <div>

View File

@ -57,6 +57,25 @@
.status-rejected { background: #f8d7da; color: #842029; } .status-rejected { background: #f8d7da; color: #842029; }
.status-billed { background: #cfe2ff; color: #084298; } .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> </style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
{% endblock %} {% endblock %}
@ -69,9 +88,10 @@
<h1 class="mb-1">Tidsregistreringer</h1> <h1 class="mb-1">Tidsregistreringer</h1>
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p> <p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
</div> </div>
<button class="btn btn-outline-primary" onclick="loadData()"> <div class="d-flex gap-2">
<i class="bi bi-arrow-clockwise"></i> Opdater <button class="btn btn-outline-success" onclick="exportReportCsv()"><i class="bi bi-file-earmark-spreadsheet"></i> Eksportér CSV</button>
</button> <button class="btn btn-outline-primary" onclick="loadData()"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
</div>
</div> </div>
<!-- Filters --> <!-- Filters -->
@ -98,8 +118,31 @@
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label> <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()"> <input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
</div> </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>
</div>
<div id="report-summary" class="report-summary mb-4"></div>
<!-- Table --> <!-- Table -->
<div class="registrations-table"> <div class="registrations-table">
@ -111,6 +154,7 @@
<th style="width: 20%;">Kunde</th> <th style="width: 20%;">Kunde</th>
<th style="width: 25%;">Beskrivelse / Case</th> <th style="width: 25%;">Beskrivelse / Case</th>
<th>Tekniker</th> <th>Tekniker</th>
<th>Type</th>
<th class="text-center">Timer</th> <th class="text-center">Timer</th>
<th class="text-center">Fakt.</th> <th class="text-center">Fakt.</th>
<th>Status</th> <th>Status</th>
@ -118,7 +162,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="table-body"> <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> </tbody>
</table> </table>
</div> </div>
@ -158,28 +202,36 @@
debounceTimer = setTimeout(loadData, 500); 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() { async function loadData() {
const tbody = document.getElementById('table-body'); const tbody = document.getElementById('table-body');
const search = document.getElementById('filter-search').value; const params = buildReportParams();
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);
try { try {
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`); const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
const data = await response.json(); const data = await response.json();
renderReportSummary(data);
if (data.times.length === 0) { 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; return;
} }
@ -203,6 +255,7 @@
<td> <td>
${t.user_name || '-'} ${t.user_name || '-'}
</td> </td>
<td><span class="badge bg-secondary">${workTypeLabels[t.work_type || 'support'] || t.work_type || 'Support'}</span></td>
<td class="text-center"> <td class="text-center">
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span> <span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
</td> </td>
@ -225,10 +278,46 @@
} catch (error) { } catch (error) {
console.error(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) { function formatDate(dateStr) {
if (!dateStr) return ''; if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('da-DK'); return new Date(dateStr).toLocaleDateString('da-DK');

View File

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

View File

@ -210,7 +210,7 @@
<div class="tab-pane fade" id="fakturaer"> <div class="tab-pane fade" id="fakturaer">
<div class="card p-4"> <div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-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> <span class="badge bg-primary" id="invoiceCount">0</span>
</div> </div>
<div class="table-responsive"> <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 latestSections = {};
let latestContextActions = { global: [], context: [] }; let latestContextActions = { global: [], context: [] };
let activeKey = 'timer'; let activeKey = 'timer';
let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' };
let overviewFilter = null; let overviewFilter = null;
let ws = null; let ws = null;
let pollTimer = null; let pollTimer = null;
@ -17,7 +18,7 @@
let switchCaseState = { let switchCaseState = {
activeTimer: null, activeTimer: null,
decision: 'unchanged', decision: 'unchanged',
timers: { active: [], paused: [] }, timers: { active: [], paused: [], stopped: [] },
recentCases: [], recentCases: [],
unassignedCases: [] unassignedCases: []
}; };
@ -855,10 +856,10 @@
if (key === 'overview') { 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 === '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 === '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> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>') : ['Ingen ESET incidents.']; 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 === '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.']; 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 = []; let out = [];
@ -872,6 +873,15 @@
out.push('<div>🎉 Alt ser grønt ud! Intet kritisk lige nu.</div>'); 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 // 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>'); 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) { if (timer.active_count > 0) {
return (timer.list || []).map(t => { return (timer.list || []).map(t => {
const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's'); 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') { if (key === 'messages') {
@ -929,9 +939,9 @@
if (key === 'tasks') { if (key === 'tasks') {
if (tasks.count > 0) { 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') { if (key === 'notes') {
@ -1153,8 +1163,12 @@
const timerChip = byId('bbActiveTimerChip'); const timerChip = byId('bbActiveTimerChip');
const timerText = byId('bbActiveTimerText'); const timerText = byId('bbActiveTimerText');
const notifCount = byId('bbNotificationsCount'); const notifCount = byId('bbNotificationsCount');
const pauseBtn = byId('bbTimerPauseBtn');
const stopBtn = byId('bbTimerStopBtn');
const timer = ((latestSections || {}).timer || {}).active || {}; const timer = ((latestSections || {}).timer || {}).active || {};
const ownTimers = ((latestSections || {}).timer || {}).own || {};
const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0;
const hasActiveTimer = !!timer.active; const hasActiveTimer = !!timer.active;
if (timerChip && timerText) { if (timerChip && timerText) {
timerChip.classList.toggle('is-hidden', !hasActiveTimer); timerChip.classList.toggle('is-hidden', !hasActiveTimer);
@ -1170,6 +1184,15 @@
const computed = Number(latestNotificationCount || 0) + unreadMessages; const computed = Number(latestNotificationCount || 0) + unreadMessages;
notifCount.textContent = String(computed); 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() { function renderTabPanel() {
@ -1193,6 +1216,7 @@
} }
const titleText = titleContainer.querySelector('.bb-tab-title-text'); const titleText = titleContainer.querySelector('.bb-tab-title-text');
const descriptionEl = byId('bbTabDescription');
const titleByKey = { const titleByKey = {
overview: 'Overblik', overview: 'Overblik',
@ -1212,12 +1236,24 @@
boss: 'bi-person-workspace' 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'; const activeTitle = titleByKey[activeKey] || 'Info';
if (titleText) { if (titleText) {
titleText.textContent = activeTitle; titleText.textContent = activeTitle;
} else { } else {
titleContainer.textContent = activeTitle; titleContainer.textContent = activeTitle;
} }
if (descriptionEl) {
descriptionEl.textContent = descriptionByKey[activeKey] || '';
}
const iconSpan = titleContainer.querySelector('.bi'); const iconSpan = titleContainer.querySelector('.bi');
if (iconSpan) { if (iconSpan) {
@ -1228,6 +1264,7 @@
const lines = listFor(activeKey, latestSections); const lines = listFor(activeKey, latestSections);
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.className = 'bb-tab-list'; ul.className = 'bb-tab-list';
ul.classList.add('bb-panel-' + activeKey);
lines.forEach(function (line) { lines.forEach(function (line) {
const li = document.createElement('li'); const li = document.createElement('li');
@ -1238,11 +1275,16 @@
innerContent.innerHTML = ''; innerContent.innerHTML = '';
if (activeKey === 'timer') {
renderTimerWorkQueue(innerContent);
return;
}
// Add specific headers/controls based on active tab // Add specific headers/controls based on active tab
if (activeKey === 'tasks') { if (activeKey === 'tasks') {
const topBar = document.createElement('div'); const topBar = document.createElement('div');
topBar.className = 'bb-task-actions mb-3'; 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); innerContent.appendChild(topBar);
} }
if (activeKey === 'messages') { 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() { function bindSideTabs() {
const buttons = document.querySelectorAll('.bb-tab-btn'); const buttons = document.querySelectorAll('.bb-tab-btn');
for (let i = 0; i < buttons.length; i++) { for (let i = 0; i < buttons.length; i++) {
@ -1680,14 +1821,15 @@
} }
function normalizeSwitchableTimerPayload(payload) { function normalizeSwitchableTimerPayload(payload) {
const out = { active: [], paused: [] }; const out = { active: [], paused: [], stopped: [] };
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return out; 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.active = Array.isArray(payload.active) ? payload.active : [];
out.paused = Array.isArray(payload.paused) ? payload.paused : []; out.paused = Array.isArray(payload.paused) ? payload.paused : [];
out.stopped = Array.isArray(payload.stopped) ? payload.stopped : [];
return out; return out;
} }
@ -1762,6 +1904,32 @@
return sagId > 0 ? ('Sag #' + sagId) : 'Ukendt sag'; 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() { function renderSwitchCaseLists() {
const timersEl = byId('bbSwitchTimersList'); const timersEl = byId('bbSwitchTimersList');
const recentEl = byId('bbSwitchRecentCasesList'); const recentEl = byId('bbSwitchRecentCasesList');
@ -1772,43 +1940,54 @@
const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : []; const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : [];
const paused = Array.isArray(switchCaseState.timers.paused) ? switchCaseState.timers.paused : []; 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 recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : [];
const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : []; const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : [];
const showUnassigned = unassignedCases.length > 0; const showUnassigned = unassignedCases.length > 0;
const searchEl = byId('bbSwitchCaseSearch');
const query = String((searchEl && searchEl.value) || '').trim().toLocaleLowerCase('da-DK');
if (actionsEl) { if (actionsEl) {
actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer); actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer);
} }
if (!active.length && !paused.length) { if (!active.length && !paused.length && !stopped.length) {
timersEl.innerHTML = '<div class="list-group-item text-muted">Ingen aktive eller pausede timere.</div>'; timersEl.innerHTML = '<div class="bb-switch-empty">Ingen tidligere timere at fortsætte.</div>';
} else { } else {
let timerItems = ''; let timerItems = '';
active.forEach(function (t) { active.forEach(function (t) {
const timeId = Number((t && (t.id || t.time_entry_id)) || 0); const timeId = Number((t && (t.id || t.time_entry_id)) || 0);
timerItems += timerItems +=
'<div class="list-group-item d-flex justify-content-between align-items-start">' + '<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-success">Aktiv</span>' + timerCaseInfo(t, true) + '</div>' +
'<div><span class="badge text-bg-success me-2">Aktiv</span>' + timerDisplayName(t) + '</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>';
'<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>';
}
}); });
paused.forEach(function (t) { paused.forEach(function (t) {
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
timerItems += timerItems +=
'<div class="list-group-item d-flex justify-content-between align-items-start">' + '<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-warning">Pauset</span>' + timerCaseInfo(t, true) + '</div>' +
'<div><span class="badge text-bg-warning me-2">Pauset</span>' + timerDisplayName(t) + '</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-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</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>';
'</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; 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'); const titleEl = byId('bbSwitchCaseModalLabel');
if (titleEl) { if (titleEl) {
titleEl.innerHTML = showUnassigned titleEl.innerHTML = showUnassigned
@ -1817,26 +1996,94 @@
} }
if (!sourceCases.length) { 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; return;
} }
recentEl.innerHTML = sourceCases.map(function (row) { recentEl.innerHTML = sourceCases.map(function (row) {
const caseId = Number((row && (row.sag_id || row.id)) || 0); 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 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 ( return (
'<div class="list-group-item d-flex justify-content-between align-items-start gap-2">' + '<div class="bb-switch-case">' +
'<div class="me-2">' + prefix + title + '</div>' + '<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="d-flex gap-1">' + '<div class="bb-switch-case-actions">' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + caseId + '">Åbn</button>' + '<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 + '">Start timer</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>' +
'</div>' '</div>'
); );
}).join(''); }).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) { async function loadSwitchCaseData(options) {
const opts = options || {}; const opts = options || {};
switchCaseState.decision = 'unchanged'; switchCaseState.decision = 'unchanged';
@ -1845,7 +2092,7 @@
: null; : null;
switchCaseState.unassignedCases = []; switchCaseState.unassignedCases = [];
switchCaseState.recentCases = []; switchCaseState.recentCases = [];
switchCaseState.timers = { active: [], paused: [] }; switchCaseState.timers = { active: [], paused: [], stopped: [] };
if (opts.onlyUnassigned) { if (opts.onlyUnassigned) {
const unassigned = (((latestSections || {}).unassigned || {}).list || []); const unassigned = (((latestSections || {}).unassigned || {}).list || []);
@ -1937,7 +2184,7 @@
if (modal) { if (modal) {
modal.hide(); modal.hide();
} }
window.location.href = '/sag/' + validCaseId; window.location.href = '/sag/' + validCaseId + '/v3';
} catch (err) { } catch (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke starte timer for sag.')); 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) { if (modal) {
modal.hide(); 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() { function resolveQuickNoteCaseId() {
const match = (window.location.pathname || '').match(/^\/sag\/(\d+)$/); const match = (window.location.pathname || '').match(/^\/sag\/(\d+)(?:\/v3)?\/?$/);
if (match && match[1]) { if (match && match[1]) {
return Number(match[1]); return Number(match[1]);
} }
@ -2319,13 +2596,6 @@
if (notificationsBtn) { if (notificationsBtn) {
notificationsBtn.addEventListener('click', function () { 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'); const trigger = byId('globalRemindersBtn');
if (trigger) { if (trigger) {
trigger.click(); trigger.click();
@ -2378,7 +2648,9 @@
if (timerChip) { if (timerChip) {
timerChip.addEventListener('click', function () { 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; return;
} }
} }
if (matched && matched.action) { executeBottomBarAction(matched);
window.location.href = matched.action;
}
}); });
} }
function bindDynamicActions() { function bindDynamicActions() {
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const target = e.target; 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'); const btn = target && target.closest('button');
if (!btn) return; 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); const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0);
if (openCaseId > 0) { if (openCaseId > 0) {
openCaseDetail(openCaseId); openCaseDetail(openCaseId);
return; 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); const startCaseId = Number(btn.getAttribute('data-bb-start-case') || 0);
if (startCaseId > 0) { if (startCaseId > 0) {
startTimerForCase(startCaseId); startTimerForCase(startCaseId);
@ -2878,7 +3196,7 @@
} }
if (bossAction === 'open_case') { if (bossAction === 'open_case') {
const caseId = Number(btn.getAttribute('data-case-id') || 0); 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; return;
} }
} }
@ -2998,6 +3316,10 @@
document.addEventListener('input', function (e) { document.addEventListener('input', function (e) {
const target = e.target; const target = e.target;
if (target && target.id === 'bbSwitchCaseSearch') {
renderSwitchCaseLists();
return;
}
if (!target || target.id !== 'bbQuickNoteInput') { if (!target || target.id !== 'bbQuickNoteInput') {
if (target && target.id === 'bbNoteTitleInput') { if (target && target.id === 'bbNoteTitleInput') {
noteEditorState.title = String(target.value || ''); noteEditorState.title = String(target.value || '');

View File

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