checkpoint: before bottom bar and side panel fixes
This commit is contained in:
parent
533a652337
commit
88f7cec478
@ -10,11 +10,11 @@
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="fw-bold" style="color: var(--primary-color);">2FA Setup</h2>
|
||||
<p class="text-muted">Opsaet tofaktor for din konto</p>
|
||||
<p class="text-muted">Opsæt tofaktor for din konto</p>
|
||||
</div>
|
||||
|
||||
<div id="statusMessage" class="alert alert-info" role="alert">
|
||||
Klik "Generer 2FA" for at starte opsaetningen.
|
||||
Klik "Generer 2FA" for at starte opsætningen.
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 mb-3">
|
||||
|
||||
@ -126,7 +126,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
|
||||
|
||||
if (data.requires_2fa_setup) {
|
||||
const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?');
|
||||
const goSetup = confirm('2FA er ikke opsat. Vil du opsætte 2FA nu?');
|
||||
window.location.href = goSetup ? '/2fa/setup' : '/';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Leverandør fakturaer - BMC Hub{% endblock %}
|
||||
{% block title %}Leverandørfakturaer - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@ -206,7 +206,7 @@
|
||||
<ul class="nav nav-tabs mb-4" id="mainTabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="unhandled-tab" data-bs-toggle="tab" href="#unhandled-content" onclick="switchToUnhandledTab()">
|
||||
<i class="bi bi-inbox me-2"></i>Ubehandlede Fakturaer
|
||||
<i class="bi bi-inbox me-2"></i>Ubehandlede fakturaer
|
||||
<span class="badge bg-warning text-dark ms-2" id="unhandledCount" style="display: none;">0</span>
|
||||
</a>
|
||||
</li>
|
||||
@ -241,7 +241,7 @@
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<i class="bi bi-inbox me-2"></i>
|
||||
<strong>Ubehandlede Fakturaer:</strong> PDFer der venter på analyse og vendor-matching. Klik "Analyser alle" for at køre automatisk extraction.
|
||||
<strong>Ubehandlede fakturaer:</strong> PDF-filer, der venter på analyse og leverandørmatch. Klik på "Analyser alle" for at køre automatisk udtrækning.
|
||||
</div>
|
||||
|
||||
<!-- Batch Actions Bar -->
|
||||
@ -349,7 +349,7 @@
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Til Betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug checkboxes til at vælge hvilke der skal betales.
|
||||
<strong>Til betaling:</strong> Fakturaer sorteret efter forfaldsdato. Brug afkrydsningsfelterne til at vælge, hvilke der skal betales.
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar for Payment -->
|
||||
@ -559,7 +559,7 @@
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="bulkCreateInvoices()" title="Opret fakturaer">
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret Fakturaer
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret fakturaer
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="bulkReprocess()" title="Genbehandle filer">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Genbehandle
|
||||
|
||||
@ -6,7 +6,14 @@ Only GET endpoints for now
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, status
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import execute_query, execute_insert
|
||||
from app.core.database import (
|
||||
execute_query,
|
||||
execute_insert,
|
||||
execute_query_single,
|
||||
get_db_connection,
|
||||
release_db_connection,
|
||||
)
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
|
||||
from app.customers.backend.router import (
|
||||
get_customer_subscriptions,
|
||||
@ -17,6 +24,7 @@ from app.customers.backend.router import (
|
||||
SubscriptionComment,
|
||||
)
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -51,6 +59,26 @@ class ContactUpdate(BaseModel):
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ContactMergeRequest(BaseModel):
|
||||
source_contact_id: int = Field(..., gt=0)
|
||||
|
||||
|
||||
CONTACT_MERGE_RELATIONS = (
|
||||
("Firmaer", "contact_companies", "contact_id"),
|
||||
("Sager", "sag_kontakter", "contact_id"),
|
||||
("Opkald", "telefoni_opkald", "kontakt_id"),
|
||||
("SMS", "sms_messages", "kontakt_id"),
|
||||
("E-mails", "tticket_email_metadata", "matched_contact_id"),
|
||||
("Tickets", "tticket_tickets", "contact_id"),
|
||||
("Ticketrelationer", "tticket_contacts", "contact_id"),
|
||||
("AnyDesk-sessioner", "anydesk_sessions", "contact_id"),
|
||||
("Forsendelser", "fedex_shipments", "contact_id"),
|
||||
("Hardware", "hardware_contacts", "contact_id"),
|
||||
("Salgsmuligheder", "pipeline_opportunity_contacts", "contact_id"),
|
||||
("Lokationer", "locations_contacts", "related_contact_id"),
|
||||
)
|
||||
|
||||
|
||||
class ContactCompanyLink(BaseModel):
|
||||
customer_id: int
|
||||
is_primary: bool = True
|
||||
@ -290,6 +318,162 @@ async def create_contact(contact: ContactCreate):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _contact_merge_counts(contact_id: int) -> list[dict]:
|
||||
counts = []
|
||||
for label, table, column in CONTACT_MERGE_RELATIONS:
|
||||
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
|
||||
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
|
||||
conversation_row = execute_query_single(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT conversation.id)::int AS count
|
||||
FROM conversations conversation
|
||||
JOIN contact_companies cc ON cc.customer_id = conversation.customer_id
|
||||
WHERE cc.contact_id = %s
|
||||
""",
|
||||
(contact_id,),
|
||||
) or {}
|
||||
counts.append({
|
||||
"key": "conversations_via_company",
|
||||
"label": "Samtaler via firma",
|
||||
"count": int(conversation_row.get("count") or 0),
|
||||
"preserved_via": "company",
|
||||
})
|
||||
return counts
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}/merge-preview")
|
||||
async def preview_contact_merge(contact_id: int, source_contact_id: int = Query(..., gt=0)):
|
||||
if contact_id == source_contact_id:
|
||||
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
|
||||
target = execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
|
||||
source = execute_query_single("SELECT * FROM contacts WHERE id = %s", (source_contact_id,))
|
||||
if not target or not source:
|
||||
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
|
||||
relations = _contact_merge_counts(source_contact_id)
|
||||
return {
|
||||
"target": target,
|
||||
"source": source,
|
||||
"relations": relations,
|
||||
"total_relations": sum(item["count"] for item in relations),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/contacts/{contact_id}/merge")
|
||||
async def merge_contact(contact_id: int, request: ContactMergeRequest):
|
||||
source_id = int(request.source_contact_id)
|
||||
if contact_id == source_id:
|
||||
raise HTTPException(status_code=400, detail="Kontakten kan ikke merges med sig selv")
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute("SELECT * FROM contacts WHERE id IN (%s, %s) FOR UPDATE", (contact_id, source_id))
|
||||
rows = cursor.fetchall()
|
||||
by_id = {int(row["id"]): dict(row) for row in rows}
|
||||
target = by_id.get(contact_id)
|
||||
source = by_id.get(source_id)
|
||||
if not target or not source:
|
||||
raise HTTPException(status_code=404, detail="En af kontakterne findes ikke")
|
||||
|
||||
moved = {}
|
||||
|
||||
# Preserve missing master data on the target contact.
|
||||
merge_fields = ("first_name", "last_name", "email", "phone", "mobile", "title", "department", "user_company")
|
||||
assignments = []
|
||||
values = []
|
||||
for field in merge_fields:
|
||||
target_value = target.get(field)
|
||||
source_value = source.get(field)
|
||||
if (target_value is None or str(target_value).strip() == "") and source_value not in (None, ""):
|
||||
assignments.append(f"{field} = %s")
|
||||
values.append(source_value)
|
||||
if assignments:
|
||||
values.append(contact_id)
|
||||
cursor.execute(f"UPDATE contacts SET {', '.join(assignments)}, updated_at = NOW() WHERE id = %s", tuple(values))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role, notes)
|
||||
SELECT %s, customer_id, is_primary, role, notes
|
||||
FROM contact_companies WHERE contact_id = %s
|
||||
ON CONFLICT (contact_id, customer_id) DO UPDATE SET
|
||||
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
|
||||
role = COALESCE(contact_companies.role, EXCLUDED.role),
|
||||
notes = COALESCE(contact_companies.notes, EXCLUDED.notes)
|
||||
""",
|
||||
(contact_id, source_id),
|
||||
)
|
||||
cursor.execute("SELECT COUNT(*)::int AS count FROM contact_companies WHERE contact_id = %s", (source_id,))
|
||||
moved["contact_companies"] = int(cursor.fetchone()["count"] or 0)
|
||||
cursor.execute("DELETE FROM contact_companies WHERE contact_id = %s", (source_id,))
|
||||
|
||||
unique_relations = (
|
||||
("hardware_contacts", "contact_id", "hardware_id", "TRUE", "TRUE"),
|
||||
("pipeline_opportunity_contacts", "contact_id", "opportunity_id", "TRUE", "TRUE"),
|
||||
("tticket_contacts", "contact_id", "ticket_id", "TRUE", "TRUE"),
|
||||
("locations_contacts", "related_contact_id", "location_id", "src.deleted_at IS NULL", "dst.deleted_at IS NULL"),
|
||||
)
|
||||
for table, column, owner_column, source_clause, target_clause in unique_relations:
|
||||
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
|
||||
moved[table] = int(cursor.fetchone()["count"] or 0)
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table} src WHERE src.{column} = %s AND {source_clause} "
|
||||
f"AND EXISTS (SELECT 1 FROM {table} dst WHERE dst.{column} = %s "
|
||||
f"AND dst.{owner_column} = src.{owner_column} AND {target_clause})",
|
||||
(source_id, contact_id),
|
||||
)
|
||||
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
|
||||
|
||||
# Avoid duplicate active case-contact rows, while retaining roles and history.
|
||||
cursor.execute("SELECT COUNT(*)::int AS count FROM sag_kontakter WHERE contact_id = %s", (source_id,))
|
||||
moved["sag_kontakter"] = int(cursor.fetchone()["count"] or 0)
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM sag_kontakter src
|
||||
WHERE src.contact_id = %s AND src.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sag_kontakter dst
|
||||
WHERE dst.contact_id = %s AND dst.sag_id = src.sag_id AND dst.deleted_at IS NULL
|
||||
)
|
||||
""",
|
||||
(source_id, contact_id),
|
||||
)
|
||||
cursor.execute("UPDATE sag_kontakter SET contact_id = %s WHERE contact_id = %s", (contact_id, source_id))
|
||||
|
||||
direct_relations = (
|
||||
("telefoni_opkald", "kontakt_id"),
|
||||
("sms_messages", "kontakt_id"),
|
||||
("tticket_email_metadata", "matched_contact_id"),
|
||||
("tticket_tickets", "contact_id"),
|
||||
("anydesk_sessions", "contact_id"),
|
||||
("fedex_shipments", "contact_id"),
|
||||
)
|
||||
for table, column in direct_relations:
|
||||
cursor.execute(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (source_id,))
|
||||
moved[table] = int(cursor.fetchone()["count"] or 0)
|
||||
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
|
||||
VALUES (%s, %s, %s::jsonb, %s::jsonb)
|
||||
""",
|
||||
(contact_id, source_id, json.dumps(source, default=str), json.dumps(moved)),
|
||||
)
|
||||
cursor.execute("DELETE FROM contacts WHERE id = %s", (source_id,))
|
||||
conn.commit()
|
||||
return {"success": True, "target_contact_id": contact_id, "merged_contact_id": source_id, "moved_relations": moved}
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.error("Failed merging contact %s into %s: %s", source_id, contact_id, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Kontakterne kunne ikke flettes. Ingen ændringer blev gemt.")
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}")
|
||||
async def get_contact(contact_id: int):
|
||||
"""Get a single contact by ID with linked companies"""
|
||||
@ -846,12 +1030,34 @@ async def get_contact_kontakt_history(contact_id: int, limit: int = Query(defaul
|
||||
FROM sms_messages s
|
||||
LEFT JOIN users u ON u.user_id = s.bruger_id
|
||||
WHERE s.kontakt_id = %s
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'merge' AS type,
|
||||
h.id::text AS event_id,
|
||||
h.merged_at AS happened_at,
|
||||
NULL::text AS direction,
|
||||
NULL::text AS number,
|
||||
CONCAT(
|
||||
'Flettet med ',
|
||||
COALESCE(NULLIF(TRIM(CONCAT(
|
||||
h.source_snapshot->>'first_name', ' ',
|
||||
h.source_snapshot->>'last_name'
|
||||
)), ''), 'kontakt #' || h.source_contact_id::text),
|
||||
' (#', h.source_contact_id::text, ')'
|
||||
) AS message,
|
||||
NULL::int AS duration_sec,
|
||||
NULL::text AS user_name,
|
||||
'completed'::text AS sms_status
|
||||
FROM contact_merge_history h
|
||||
WHERE h.target_contact_id = %s
|
||||
) z
|
||||
ORDER BY z.happened_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
rows = execute_query(query, (contact_id, contact_id, limit)) or []
|
||||
rows = execute_query(query, (contact_id, contact_id, contact_id, limit)) or []
|
||||
return {"items": rows}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@ -134,6 +134,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-light btn-sm" onclick="openMergeContactModal()" title="Flet en dubletkontakt ind i denne kontakt">
|
||||
<i class="bi bi-intersect me-2"></i>Flet dublet
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('contact', contactId)" title="Opret vigtig information/advarsel om denne kontakt">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
||||
</button>
|
||||
@ -705,6 +708,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge duplicate contact modal -->
|
||||
<div class="modal fade" id="mergeContactModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title"><i class="bi bi-intersect me-2"></i>Flet dubletkontakt</h5>
|
||||
<div class="small text-muted">Denne kontakt beholdes som hovedkontakt. Den valgte dublet slettes efter flytning.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-primary py-2">
|
||||
<strong>Hovedkontakt:</strong> <span id="mergeTargetName">-</span>
|
||||
</div>
|
||||
<label class="form-label fw-semibold" for="mergeContactSearch">Find dublet</label>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input id="mergeContactSearch" class="form-control" type="search" placeholder="Søg på navn, e-mail, telefon eller firma" autocomplete="off">
|
||||
</div>
|
||||
<div id="mergeContactResults" class="list-group mb-3"></div>
|
||||
<div id="mergeContactPreview" class="d-none">
|
||||
<div class="border rounded p-3">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div><div class="small text-muted">Dublet der fjernes</div><strong id="mergeSourceName"></strong><div class="small text-muted" id="mergeSourceDetails"></div></div>
|
||||
<span class="badge text-bg-warning">Kan ikke fortrydes direkte</span>
|
||||
</div>
|
||||
<div class="small fw-semibold mb-2">Relateret information der flyttes</div>
|
||||
<div id="mergeRelationCounts" class="d-flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button id="mergeContactSubmit" type="button" class="btn btn-danger" onclick="submitContactMerge()" disabled>
|
||||
<i class="bi bi-intersect me-2"></i>Flet kontakter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Contact Modal -->
|
||||
<div class="modal fade" id="editContactModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@ -802,11 +847,21 @@ let contactData = null;
|
||||
let primaryCustomerId = null;
|
||||
let kontaktHistoryItems = [];
|
||||
let kontaktHistoryFilter = 'all';
|
||||
let mergeSourceContactId = null;
|
||||
let mergeSearchTimer = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContact();
|
||||
loadCompaniesForSelect();
|
||||
|
||||
const mergeSearch = document.getElementById('mergeContactSearch');
|
||||
if (mergeSearch) {
|
||||
mergeSearch.addEventListener('input', () => {
|
||||
clearTimeout(mergeSearchTimer);
|
||||
mergeSearchTimer = setTimeout(() => searchMergeContacts(mergeSearch.value), 250);
|
||||
});
|
||||
}
|
||||
|
||||
// Load companies when tab is shown
|
||||
document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => {
|
||||
loadCompanies();
|
||||
@ -1882,7 +1937,7 @@ function renderKontaktHistoryTable() {
|
||||
if (!filteredItems.length) {
|
||||
const msg = kontaktHistoryItems.length
|
||||
? 'Ingen hændelser matcher filteret'
|
||||
: 'Ingen opkald eller SMS fundet';
|
||||
: 'Ingen kontakthistorik fundet';
|
||||
container.innerHTML = `<div class="text-muted text-center py-5">${msg}</div>`;
|
||||
return;
|
||||
}
|
||||
@ -1913,13 +1968,17 @@ function renderKontaktHistoryRow(item) {
|
||||
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
|
||||
const typeBadge = item.type === 'sms'
|
||||
? '<span class="badge bg-primary-subtle text-primary-emphasis">SMS</span>'
|
||||
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
|
||||
: item.type === 'merge'
|
||||
? '<span class="badge bg-info-subtle text-info-emphasis">Fletning</span>'
|
||||
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
|
||||
|
||||
const dirOrStatus = item.type === 'sms'
|
||||
? (item.sms_status || '-')
|
||||
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
|
||||
: item.type === 'merge'
|
||||
? 'Gennemført'
|
||||
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
|
||||
|
||||
const message = item.type === 'sms'
|
||||
const message = item.type === 'sms' || item.type === 'merge'
|
||||
? escapeHtml(item.message || '-')
|
||||
: '-';
|
||||
|
||||
@ -2327,6 +2386,106 @@ async function saveEditContact() {
|
||||
}
|
||||
}
|
||||
|
||||
function contactMergeName(contact) {
|
||||
if (!contact) return 'Ukendt kontakt';
|
||||
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|
||||
|| contact.email
|
||||
|| `Kontakt #${contact.id}`;
|
||||
}
|
||||
|
||||
function openMergeContactModal() {
|
||||
mergeSourceContactId = null;
|
||||
const targetName = contactMergeName(contactData);
|
||||
const automaticSearch = `${contactData?.first_name || ''} ${contactData?.last_name || ''}`.trim();
|
||||
document.getElementById('mergeTargetName').textContent = targetName;
|
||||
document.getElementById('mergeContactSearch').value = automaticSearch;
|
||||
document.getElementById('mergeContactResults').innerHTML = '<div class="text-muted small">Søger efter mulige dubletter…</div>';
|
||||
document.getElementById('mergeContactPreview').classList.add('d-none');
|
||||
document.getElementById('mergeContactSubmit').disabled = true;
|
||||
const modalElement = document.getElementById('mergeContactModal');
|
||||
bootstrap.Modal.getOrCreateInstance(modalElement).show();
|
||||
modalElement.addEventListener('shown.bs.modal', () => document.getElementById('mergeContactSearch').focus(), { once: true });
|
||||
if (automaticSearch.length >= 2) {
|
||||
searchMergeContacts(automaticSearch);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMergeContacts(query) {
|
||||
const results = document.getElementById('mergeContactResults');
|
||||
const value = query.trim();
|
||||
if (value.length < 2) {
|
||||
results.innerHTML = '<div class="text-muted small">Skriv mindst 2 tegn.</div>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger…</div>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(value)}&limit=20`);
|
||||
if (!response.ok) throw new Error('Søgningen fejlede');
|
||||
const data = await response.json();
|
||||
const contacts = (data.contacts || []).filter(contact => Number(contact.id) !== contactId);
|
||||
if (!contacts.length) {
|
||||
results.innerHTML = '<div class="text-muted small">Ingen andre kontakter fundet.</div>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = contacts.map(contact => {
|
||||
const companies = (contact.company_names || []).join(', ') || 'Intet firma';
|
||||
const details = [contact.email, contact.mobile || contact.phone, companies].filter(Boolean).join(' · ');
|
||||
return `<button type="button" class="list-group-item list-group-item-action" onclick="selectMergeContact(${Number(contact.id)})">
|
||||
<div class="fw-semibold">${escapeHtml(contactMergeName(contact))}</div>
|
||||
<div class="small text-muted">#${Number(contact.id)} · ${escapeHtml(details)}</div>
|
||||
</button>`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
results.innerHTML = `<div class="text-danger small">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMergeContact(sourceId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}/merge-preview?source_contact_id=${sourceId}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente fletteoversigt');
|
||||
mergeSourceContactId = sourceId;
|
||||
document.getElementById('mergeSourceName').textContent = contactMergeName(data.source);
|
||||
document.getElementById('mergeSourceDetails').textContent = [
|
||||
`Kontakt #${data.source.id}`,
|
||||
data.source.email,
|
||||
data.source.mobile || data.source.phone,
|
||||
].filter(Boolean).join(' · ');
|
||||
const relevant = (data.relations || []).filter(item => Number(item.count) > 0);
|
||||
document.getElementById('mergeRelationCounts').innerHTML = relevant.length
|
||||
? relevant.map(item => `<span class="badge text-bg-light border">${escapeHtml(item.label)}: ${Number(item.count)}</span>`).join('')
|
||||
: '<span class="text-muted small">Ingen direkte relationer fundet.</span>';
|
||||
document.getElementById('mergeContactPreview').classList.remove('d-none');
|
||||
document.getElementById('mergeContactSubmit').disabled = false;
|
||||
} catch (error) {
|
||||
alert(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitContactMerge() {
|
||||
if (!mergeSourceContactId) return;
|
||||
const sourceName = document.getElementById('mergeSourceName').textContent;
|
||||
if (!confirm(`Flet ${sourceName} ind i ${contactMergeName(contactData)}?\n\nSager, firmaer, e-mails, opkald og øvrige relationer flyttes. Dubletkontakten slettes.`)) return;
|
||||
const button = document.getElementById('mergeContactSubmit');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}/merge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_contact_id: mergeSourceContactId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Kontakterne kunne ikke flettes');
|
||||
bootstrap.Modal.getInstance(document.getElementById('mergeContactModal'))?.hide();
|
||||
alert('Kontakterne er flettet. Alle relationer er samlet på hovedkontakten.');
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
alert(`Fejl: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(firstName, lastName) {
|
||||
if (!firstName && !lastName) return '?';
|
||||
const first = firstName ? firstName[0] : '';
|
||||
|
||||
@ -189,6 +189,7 @@ async def search_sag(q: str):
|
||||
s.beskrivelse,
|
||||
s.status,
|
||||
s.created_at,
|
||||
(s.deleted_at IS NOT NULL) AS is_archived,
|
||||
s.customer_id,
|
||||
c.name as customer_name,
|
||||
ARRAY(
|
||||
@ -202,8 +203,7 @@ async def search_sag(q: str):
|
||||
) AS buzzwords
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND (
|
||||
WHERE (
|
||||
CAST(s.id AS TEXT) ILIKE %s OR
|
||||
s.titel ILIKE %s OR
|
||||
s.beskrivelse ILIKE %s OR
|
||||
|
||||
@ -965,7 +965,7 @@
|
||||
<script>
|
||||
(() => {
|
||||
const kpiLabels = {
|
||||
open_cases: 'Aabne sager',
|
||||
open_cases: 'Åbne sager',
|
||||
new_cases: 'Nye sager',
|
||||
unassigned_cases: 'Uden ansvarlig',
|
||||
deadlines_today: 'Deadline i dag',
|
||||
@ -1362,7 +1362,7 @@
|
||||
}
|
||||
|
||||
const call = state.activeCalls[0];
|
||||
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
|
||||
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
|
||||
const parts = [];
|
||||
if (call.contact_name) parts.push(call.contact_name);
|
||||
if (call.company_name) parts.push(call.company_name);
|
||||
@ -1381,7 +1381,7 @@
|
||||
|
||||
list.innerHTML = state.activeCalls.map((call) => `
|
||||
<div class="mc-feed-item">
|
||||
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
|
||||
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
|
||||
<div class="mc-feed-meta">
|
||||
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
|
||||
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
|
||||
@ -1736,7 +1736,7 @@
|
||||
function renderMotionBadge() {
|
||||
if (state.cameraMotion && state.cameraMotion.motion) {
|
||||
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
|
||||
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
||||
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
||||
return;
|
||||
}
|
||||
setMotionBadge('');
|
||||
|
||||
@ -965,7 +965,7 @@
|
||||
<script>
|
||||
(() => {
|
||||
const kpiLabels = {
|
||||
open_cases: 'Aabne sager',
|
||||
open_cases: 'Åbne sager',
|
||||
new_cases: 'Nye sager',
|
||||
unassigned_cases: 'Uden ansvarlig',
|
||||
deadlines_today: 'Deadline i dag',
|
||||
@ -1362,7 +1362,7 @@
|
||||
}
|
||||
|
||||
const call = state.activeCalls[0];
|
||||
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
|
||||
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
|
||||
const parts = [];
|
||||
if (call.contact_name) parts.push(call.contact_name);
|
||||
if (call.company_name) parts.push(call.company_name);
|
||||
@ -1381,7 +1381,7 @@
|
||||
|
||||
list.innerHTML = state.activeCalls.map((call) => `
|
||||
<div class="mc-feed-item">
|
||||
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
|
||||
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
|
||||
<div class="mc-feed-meta">
|
||||
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
|
||||
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
|
||||
@ -1736,7 +1736,7 @@
|
||||
function renderMotionBadge() {
|
||||
if (state.cameraMotion && state.cameraMotion.motion) {
|
||||
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
|
||||
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
||||
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
||||
return;
|
||||
}
|
||||
setMotionBadge('');
|
||||
|
||||
@ -1935,7 +1935,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="modal-body mc-day-modal-body">
|
||||
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vaelg en sag fra listen.</div>
|
||||
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vælg en sag fra listen.</div>
|
||||
|
||||
<div>
|
||||
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
|
||||
@ -2070,7 +2070,7 @@
|
||||
const projectOnlyMode = {{ 'true' if project_only else 'false' }};
|
||||
|
||||
const kpiLabels = {
|
||||
open_cases: 'Aabne sager',
|
||||
open_cases: 'Åbne sager',
|
||||
new_cases: 'Nye sager',
|
||||
unassigned_cases: 'Uden ansvarlig',
|
||||
deadlines_today: 'Deadline i dag',
|
||||
@ -2956,7 +2956,7 @@
|
||||
}
|
||||
|
||||
const call = state.activeCalls[0];
|
||||
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
|
||||
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
|
||||
const parts = [];
|
||||
if (call.contact_name) parts.push(call.contact_name);
|
||||
if (call.company_name) parts.push(call.company_name);
|
||||
@ -2975,7 +2975,7 @@
|
||||
|
||||
list.innerHTML = state.activeCalls.map((call) => `
|
||||
<div class="mc-feed-item">
|
||||
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
|
||||
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
|
||||
<div class="mc-feed-meta">
|
||||
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
|
||||
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
|
||||
@ -3571,7 +3571,7 @@
|
||||
const userSelect = document.getElementById(`assignUser-${id}`);
|
||||
const userId = toOptionalInt(userSelect?.value);
|
||||
if (userId === null) {
|
||||
alert('Vaelg en medarbejder foerst.');
|
||||
alert('Vælg en medarbejder først.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3587,7 +3587,7 @@
|
||||
const groupSelect = document.getElementById(`assignGroup-${id}`);
|
||||
const groupId = toOptionalInt(groupSelect?.value);
|
||||
if (groupId === null) {
|
||||
alert('Vaelg en gruppe foerst.');
|
||||
alert('Vælg en gruppe først.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3768,7 +3768,7 @@
|
||||
function renderMotionBadge() {
|
||||
if (state.cameraMotion && state.cameraMotion.motion) {
|
||||
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
|
||||
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
||||
setMotionBadge(`Bevægelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
|
||||
return;
|
||||
}
|
||||
setMotionBadge('');
|
||||
|
||||
@ -447,7 +447,7 @@
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
|
||||
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overførsel til e-conomic)');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
@ -462,7 +462,7 @@
|
||||
const skipped = (result.skipped_missing_customer || []);
|
||||
const failedCustomers = (result.failed_customers || []);
|
||||
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
|
||||
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
|
||||
const nextStep = result.orders_url ? `\n\nÅbn ordre: ${result.orders_url}` : '';
|
||||
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
||||
const failedMsg = failedCustomers.length
|
||||
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@ -854,22 +855,25 @@ def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"context_key": "global",
|
||||
"global": [
|
||||
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
|
||||
{"id": "new_case", "label": "Ny sag", "action": "/sag/new", "icon": "bi-plus-circle"},
|
||||
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
|
||||
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
|
||||
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
|
||||
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
|
||||
{"id": "start_timer", "label": "Skift/start timer", "command": "switch_timer", "icon": "bi-stopwatch"},
|
||||
{"id": "log_time", "label": "Tidsoversigt", "action": "/timetracking/registrations", "icon": "bi-clock-history"},
|
||||
{"id": "add_note", "label": "Ny personlig note", "command": "open_notes", "icon": "bi-journal-plus"},
|
||||
],
|
||||
"context": [],
|
||||
}
|
||||
|
||||
if normalized.startswith("/sag"):
|
||||
case_match = re.match(r"^/sag/(\d+)(?:/v3)?/?$", normalized)
|
||||
if case_match:
|
||||
case_id = int(case_match.group(1))
|
||||
payload["context_key"] = "sag"
|
||||
payload["case_id"] = case_id
|
||||
payload["context"] = [
|
||||
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
|
||||
{"id": "case_mail", "label": "Mail", "action": "/emails"},
|
||||
{"id": "case_relation", "label": "Relation", "action": "/customers"},
|
||||
{"id": "case_tag", "label": "Tag", "action": "/tags"},
|
||||
{"id": "case_time", "label": "Registrér tid", "command": "case_add:time", "icon": "bi-clock"},
|
||||
{"id": "case_note", "label": "Tilføj kommentar", "command": "case_add:note", "icon": "bi-chat-left-text"},
|
||||
{"id": "case_mail", "label": "Send mail", "command": "case_add:email", "icon": "bi-envelope"},
|
||||
{"id": "case_subscription", "label": "Abonnement", "command": "case_add:subscription", "icon": "bi-arrow-repeat"},
|
||||
]
|
||||
elif normalized.startswith("/hardware"):
|
||||
payload["context_key"] = "hardware"
|
||||
|
||||
@ -502,12 +502,12 @@
|
||||
<div class="calendar-shell fade-up">
|
||||
<div class="calendar-toolbar">
|
||||
<div class="view-buttons" id="viewButtons">
|
||||
<button type="button" data-view="dayGridMonth" class="active">Maaned</button>
|
||||
<button type="button" data-view="dayGridMonth" class="active">Måned</button>
|
||||
<button type="button" data-view="timeGridWeek">Uge</button>
|
||||
<button type="button" data-view="timeGridDay">Dag</button>
|
||||
<button type="button" data-view="listWeek">Agenda</button>
|
||||
</div>
|
||||
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div>
|
||||
<div class="status-bar" id="rangeLabel">Indlæser periode...</div>
|
||||
</div>
|
||||
<div id="calendar"></div>
|
||||
<div class="calendar-legend">
|
||||
|
||||
@ -883,7 +883,7 @@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<div class="stat-tile">
|
||||
<div class="stat-label">Total omsaetning</div>
|
||||
<div class="stat-label">Total omsætning</div>
|
||||
<div class="stat-value">{{ "{:,.2f}".format(rental_stats.total_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
|
||||
<div class="stat-helper">Fra alle ordrelinjer pa dette asset</div>
|
||||
</div>
|
||||
@ -958,7 +958,7 @@
|
||||
<th>Dato</th>
|
||||
<th>Titel</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Beloeb</th>
|
||||
<th class="text-end">Beløb</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -1054,13 +1054,13 @@
|
||||
Opretter abonnement, aktiv asset-binding og ordrekladde i et flow.
|
||||
</div>
|
||||
<div id="quickRentPlanInfo" class="alert alert-secondary py-2 small mb-3">
|
||||
Vaelg kunde og sag for at se hvad der bliver oprettet.
|
||||
Vælg kunde og sag for at se, hvad der bliver oprettet.
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kunde</label>
|
||||
<select class="form-select" id="quickRentCustomerId" required>
|
||||
<option value="">-- Vaelg kunde --</option>
|
||||
<option value="">-- Vælg kunde --</option>
|
||||
{% for customer in owner_customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.navn }}</option>
|
||||
{% endfor %}
|
||||
@ -1480,11 +1480,11 @@
|
||||
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
|
||||
|
||||
if (!customerId || !sagId || !startDate) {
|
||||
alert('Kunde, sag og startdato er paakraevet.');
|
||||
alert('Kunde, sag og startdato er påkrævet.');
|
||||
return;
|
||||
}
|
||||
if (operationsMonthlyPrice <= 0) {
|
||||
alert('Drift pr. maned skal vaere over 0.');
|
||||
alert('Drift pr. måned skal være over 0.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1548,14 +1548,14 @@
|
||||
|
||||
if (!customerId || !sagId) {
|
||||
infoEl.className = 'alert alert-secondary py-2 small mb-3';
|
||||
infoEl.textContent = 'Vaelg kunde og sag for at se hvad der bliver oprettet.';
|
||||
infoEl.textContent = 'Vælg kunde og sag for at se, hvad der bliver oprettet.';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
infoEl.className = 'alert alert-secondary py-2 small mb-3';
|
||||
infoEl.textContent = 'Tjekker abonnement paa sagen...';
|
||||
infoEl.textContent = 'Tjekker abonnement på sagen...';
|
||||
|
||||
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
|
||||
const data = await response.json();
|
||||
@ -1578,7 +1578,7 @@
|
||||
} catch (error) {
|
||||
submitBtn.disabled = false;
|
||||
infoEl.className = 'alert alert-danger py-2 small mb-3';
|
||||
infoEl.textContent = `Preview fejl: ${error.message}. Du kan stadig prove at oprette.`;
|
||||
infoEl.textContent = `Fejl i forhåndsvisning: ${error.message}. Du kan stadig prøve at oprette.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -167,7 +167,7 @@
|
||||
|
||||
<div class="section-card" id="devicesSection">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
|
||||
<div class="status-pill" id="deviceStatus">Ingen data indlæst</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" onclick="runOnePcFullTest()">Test 1 PC (ALT)</button>
|
||||
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
|
||||
@ -219,7 +219,7 @@
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
|
||||
</div>
|
||||
<div id="contactResults" class="contact-results"></div>
|
||||
<div id="contactHint" class="contact-muted">Tip: Skriv 2+ tegn og tryk Enter for at vaelge den forste.</div>
|
||||
<div id="contactHint" class="contact-muted">Tip: Skriv mindst 2 tegn, og tryk Enter for at vælge den første.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -365,7 +365,7 @@
|
||||
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
|
||||
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label small">Vaelg kontakt (ejer)</label>
|
||||
<label class="form-label small">Vælg kontakt (ejer)</label>
|
||||
<input type="text" class="form-control form-control-sm inline-contact-search" data-index="${index}" placeholder="Sog kontakt..." autocomplete="off">
|
||||
<div id="inlineResults-${index}" class="inline-results"></div>
|
||||
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
|
||||
@ -409,7 +409,7 @@
|
||||
async function searchInlineContacts(query, index) {
|
||||
const results = document.getElementById(`inlineResults-${index}`);
|
||||
if (!results) return;
|
||||
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
|
||||
results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
|
||||
@ -571,10 +571,10 @@
|
||||
const query = document.getElementById('contactSearch').value.trim();
|
||||
const results = document.getElementById('contactResults');
|
||||
if (!query) {
|
||||
results.innerHTML = '<div class="p-2 text-muted">Indtast soegning.</div>';
|
||||
results.innerHTML = '<div class="p-2 text-muted">Indtast søgning.</div>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
|
||||
results.innerHTML = '<div class="p-2 text-muted">Søger...</div>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
|
||||
if (!response.ok) {
|
||||
|
||||
@ -1136,7 +1136,7 @@
|
||||
const payload = getFormPayload();
|
||||
|
||||
if (!payload.name) {
|
||||
alert('Navn er paakraevet.');
|
||||
alert('Navn er påkrævet.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -196,6 +196,7 @@ def _fetch_closed_case_statuses() -> list[str]:
|
||||
async def sager_liste(
|
||||
request: Request,
|
||||
status: str = Query(None),
|
||||
priority: str = Query(None),
|
||||
tag: str = Query(None),
|
||||
customer_id: str = Query(None),
|
||||
ansvarlig_bruger_id: str = Query(None),
|
||||
@ -278,6 +279,7 @@ async def sager_liste(
|
||||
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
|
||||
|
||||
normalized_status = str(status or "").strip().lower()
|
||||
normalized_priority = str(priority or "").strip().lower()
|
||||
if normalized_status == "all":
|
||||
pass
|
||||
elif normalized_status:
|
||||
@ -287,6 +289,9 @@ async def sager_liste(
|
||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||
params.extend(closed_statuses)
|
||||
if normalized_priority:
|
||||
query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
|
||||
params.append(normalized_priority)
|
||||
if customer_id_int:
|
||||
query += " AND s.customer_id = %s"
|
||||
params.append(customer_id_int)
|
||||
@ -350,6 +355,9 @@ async def sager_liste(
|
||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||
fallback_params.extend(closed_statuses)
|
||||
if normalized_priority:
|
||||
fallback_query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
|
||||
fallback_params.append(normalized_priority)
|
||||
if customer_id_int:
|
||||
fallback_query += " AND s.customer_id = %s"
|
||||
fallback_params.append(customer_id_int)
|
||||
@ -429,6 +437,7 @@ async def sager_liste(
|
||||
"statuses": status_options,
|
||||
"all_tags": [t['tag_navn'] for t in all_tags],
|
||||
"current_status": status,
|
||||
"current_priority": normalized_priority,
|
||||
"current_tag": tag,
|
||||
"include_deferred": include_deferred,
|
||||
"toggle_include_deferred_url": toggle_include_deferred_url,
|
||||
@ -450,6 +459,7 @@ async def sager_liste(
|
||||
"statuses": _fetch_case_status_options(),
|
||||
"all_tags": [],
|
||||
"current_status": status,
|
||||
"current_priority": str(priority or "").strip().lower(),
|
||||
"current_tag": tag,
|
||||
"include_deferred": include_deferred,
|
||||
"toggle_include_deferred_url": str(request.url),
|
||||
|
||||
@ -1020,7 +1020,8 @@
|
||||
|
||||
const caseTypeLabels = {
|
||||
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
|
||||
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
|
||||
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
|
||||
abonnement: '🔁 Abonnement'
|
||||
};
|
||||
|
||||
function updateCaseTypeSections() {
|
||||
@ -1109,6 +1110,7 @@
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
|
||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||
if (!types.includes('abonnement')) types.push('abonnement');
|
||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
||||
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -198,6 +198,7 @@
|
||||
<option value="ordre" {% if (case.template_key or case.type) == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
|
||||
<option value="projekt" {% if (case.template_key or case.type) == 'projekt' %}selected{% endif %}>📁 Projekt</option>
|
||||
<option value="service" {% if (case.template_key or case.type) == 'service' %}selected{% endif %}>🛠️ Service</option>
|
||||
<option value="abonnement" {% if (case.template_key or case.type) == 'abonnement' %}selected{% endif %}>🔁 Abonnement</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -260,6 +261,7 @@
|
||||
if (!Array.isArray(types) || types.length === 0) return;
|
||||
|
||||
const typeSet = new Set(types);
|
||||
typeSet.add('abonnement');
|
||||
if (currentType) {
|
||||
typeSet.add(currentType);
|
||||
}
|
||||
|
||||
@ -1230,7 +1230,7 @@
|
||||
const options = Array.from(typeFilter.options || []);
|
||||
if (options.length === 0) {
|
||||
typeFilterCheckboxList.innerHTML = '<span class="type-filter-empty">Ingen typer fundet</span>';
|
||||
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgaengelige';
|
||||
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgængelige';
|
||||
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -190,7 +190,7 @@ let currentCardStatus = '';
|
||||
|
||||
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
|
||||
{ label: 'Haster', text: 'Haster', multiplier: 3 },
|
||||
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
|
||||
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
|
||||
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
|
||||
];
|
||||
|
||||
|
||||
@ -107,7 +107,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note</label>
|
||||
<input type="text" class="form-control" id="priceNote" placeholder="Aarsag til prisændring">
|
||||
<input type="text" class="form-control" id="priceNote" placeholder="Årsag til prisændring">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Opdateret af</label>
|
||||
@ -117,24 +117,24 @@
|
||||
<div class="small mt-3" id="priceUpdateMessage"></div>
|
||||
</div>
|
||||
<div class="product-card mt-3">
|
||||
<h5 class="mb-3">Opdater leverandoer</h5>
|
||||
<h5 class="mb-3">Opdater leverandør</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Leverandoer</label>
|
||||
<input type="text" class="form-control" id="supplierName" placeholder="Leverandoer navn">
|
||||
<label class="form-label">Leverandør</label>
|
||||
<input type="text" class="form-control" id="supplierName" placeholder="Leverandørnavn">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Leverandoer pris</label>
|
||||
<label class="form-label">Leverandørpris</label>
|
||||
<input type="number" class="form-control" id="supplierPrice" step="0.01" min="0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note</label>
|
||||
<input type="text" class="form-control" id="supplierNote" placeholder="Aarsag til ændring">
|
||||
<input type="text" class="form-control" id="supplierNote" placeholder="Årsag til ændring">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Opdateret af</label>
|
||||
<input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn">
|
||||
</div>
|
||||
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandoer</button>
|
||||
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandør</button>
|
||||
<div class="small mt-3" id="supplierUpdateMessage"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -144,7 +144,7 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm product-table mb-0">
|
||||
<tbody id="productInfoBody">
|
||||
<tr><td class="text-center product-muted py-3">Indlaeser...</td></tr>
|
||||
<tr><td class="text-center product-muted py-3">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -164,7 +164,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="priceHistoryBody">
|
||||
<tr><td colspan="6" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
||||
<tr><td colspan="6" class="text-center product-muted py-3">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -186,7 +186,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesHistoryBody">
|
||||
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
||||
<tr><td colspan="7" class="text-center product-muted py-3">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -218,7 +218,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="supplierListBody">
|
||||
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
||||
<tr><td colspan="7" class="text-center product-muted py-3">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -256,7 +256,7 @@
|
||||
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilfoej/Opdater</button>
|
||||
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="small" id="supplierListMessage"></div>
|
||||
@ -356,9 +356,9 @@ function renderProductInfo(product) {
|
||||
['Status', product.status],
|
||||
['SKU intern', product.sku_internal],
|
||||
['Producent', product.manufacturer],
|
||||
['Leverandoer', product.supplier_name],
|
||||
['Leverandoer SKU', product.supplier_sku],
|
||||
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
|
||||
['Leverandør', product.supplier_name],
|
||||
['Leverandør-SKU', product.supplier_sku],
|
||||
['Leverandørpris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
|
||||
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
|
||||
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
|
||||
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : null],
|
||||
@ -399,8 +399,8 @@ async function loadProductDetail() {
|
||||
document.getElementById('productPrice').textContent = product.sales_price != null ? formatCurrency(product.sales_price) : '-';
|
||||
document.getElementById('productSku').textContent = product.sku_internal || '-';
|
||||
document.getElementById('productSupplierPrice').textContent = product.supplier_price != null
|
||||
? `Leverandoer: ${formatCurrency(product.supplier_price)}`
|
||||
: 'Leverandoer pris: -';
|
||||
? `Leverandør: ${formatCurrency(product.supplier_price)}`
|
||||
: 'Leverandørpris: -';
|
||||
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
|
||||
document.getElementById('supplierName').value = product.supplier_name || '';
|
||||
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
|
||||
@ -409,7 +409,7 @@ async function loadProductDetail() {
|
||||
renderProductInfo(product);
|
||||
prefillSupplierSku(product);
|
||||
} catch (e) {
|
||||
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
|
||||
setMessage(e.message || 'Fejl ved indlæsning', 'text-danger');
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,7 +426,7 @@ async function loadPriceHistory() {
|
||||
tbody.innerHTML = history.map(entry => `
|
||||
<tr>
|
||||
<td>${formatDate(entry.changed_at)}</td>
|
||||
<td>${entry.price_type === 'supplier_price' ? 'Leverandoer' : 'Salgspris'}</td>
|
||||
<td>${entry.price_type === 'supplier_price' ? 'Leverandør' : 'Salgspris'}</td>
|
||||
<td>${entry.old_price != null ? formatCurrency(entry.old_price) : '-'}</td>
|
||||
<td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td>
|
||||
<td>${escapeHtml(entry.note || '-')}</td>
|
||||
@ -693,10 +693,10 @@ async function submitSupplierUpdate() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || 'Leverandoer opdatering fejlede');
|
||||
throw new Error(error.detail || 'Opdatering af leverandør fejlede');
|
||||
}
|
||||
await res.json();
|
||||
setSupplierMessage('Leverandoer opdateret', 'text-success');
|
||||
setSupplierMessage('Leverandør opdateret', 'text-success');
|
||||
await loadProductDetail();
|
||||
await loadPriceHistory();
|
||||
} catch (e) {
|
||||
|
||||
@ -210,7 +210,7 @@
|
||||
<div class="products-search">
|
||||
<div class="input-group" style="min-width: 260px;">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="localSearchQuery" placeholder="Soeg lokale produkter eller EAN/strengkode...">
|
||||
<input type="text" class="form-control" id="localSearchQuery" placeholder="Søg lokale produkter eller EAN/stregkode...">
|
||||
</div>
|
||||
<select class="form-select" id="localSearchStatus" style="max-width: 140px;">
|
||||
<option value="active" selected>Aktiv</option>
|
||||
@ -232,10 +232,10 @@
|
||||
<option value="false">Nej</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
|
||||
<i class="bi bi-search"></i> Soeg
|
||||
<i class="bi bi-search"></i> Søg
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
|
||||
<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)
|
||||
<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
|
||||
<i class="bi bi-x"></i> Nulstil
|
||||
@ -256,7 +256,7 @@
|
||||
<span class="text-muted small" id="localProductsPageInfo"></span>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" id="localPrevPage" onclick="changeLocalPage(-1)">Forrige</button>
|
||||
<button class="btn btn-outline-secondary" id="localNextPage" onclick="changeLocalPage(1)">Naeste</button>
|
||||
<button class="btn btn-outline-secondary" id="localNextPage" onclick="changeLocalPage(1)">Næste</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -277,7 +277,7 @@
|
||||
<tbody id="productsBody">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-5">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -298,14 +298,14 @@
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Soeg efter produktnavn...">
|
||||
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Søg efter produktnavn...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Supplier code">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary w-100" onclick="searchGatewayProducts(true)">
|
||||
<i class="bi bi-search"></i> Soeg
|
||||
<i class="bi bi-search"></i> Søg
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@ -317,7 +317,7 @@
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="gatewayModalInStock">
|
||||
<label class="form-check-label" for="gatewayModalInStock">Kun paa lager</label>
|
||||
<label class="form-check-label" for="gatewayModalInStock">Kun på lager</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@ -328,7 +328,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="gatewayResults">
|
||||
<div class="text-center text-muted py-4">Soeg efter produkter for at importere.</div>
|
||||
<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -352,13 +352,13 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" id="productType">
|
||||
<option value="">- Vaelg type -</option>
|
||||
<option value="">- Vælg type -</option>
|
||||
<option value="hardware">Hardware</option>
|
||||
<option value="service">Service</option>
|
||||
<option value="subscription">Abonnement</option>
|
||||
<option value="bundle">Bundle</option>
|
||||
</select>
|
||||
<div class="form-text">Vaelg produkttype for korrekt kategorisering.</div>
|
||||
<div class="form-text">Vælg produkttype for korrekt kategorisering.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Status</label>
|
||||
@ -397,9 +397,9 @@
|
||||
<label class="form-label">Faktureringsinterval</label>
|
||||
<select class="form-select" id="productBillingPeriod">
|
||||
<option value="">-</option>
|
||||
<option value="monthly">Maaned</option>
|
||||
<option value="monthly">Måned</option>
|
||||
<option value="quarterly">Kvartal</option>
|
||||
<option value="yearly">Aar</option>
|
||||
<option value="yearly">År</option>
|
||||
<option value="one_time">Engang</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -424,7 +424,7 @@
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert alert-light small">
|
||||
Avancerede felter kan tilfoejes senere via API.
|
||||
Avancerede felter kan tilføjes senere via API.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -461,7 +461,7 @@ async function loadProducts() {
|
||||
document.getElementById('productsBody').innerHTML = `
|
||||
<tr><td colspan="6" class="text-center text-danger py-5">
|
||||
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
||||
<p>Fejl ved indlaesning</p>
|
||||
<p>Fejl ved indlæsning</p>
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
@ -609,14 +609,14 @@ async function searchAndCreateByGatewayCode() {
|
||||
const code = queryInput ? queryInput.value.trim() : '';
|
||||
|
||||
if (!code) {
|
||||
alert('Skriv EAN eller strengkode i soegefeltet foerst');
|
||||
alert('Skriv EAN eller stregkode i søgefeltet først');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHtml = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Soeger...';
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Søger...';
|
||||
}
|
||||
|
||||
try {
|
||||
@ -652,7 +652,7 @@ async function searchAndCreateByGatewayCode() {
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Soeg i APIGateway (strengkode)';
|
||||
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -751,9 +751,9 @@ function formatCurrency(amount) {
|
||||
|
||||
function formatInterval(interval) {
|
||||
const map = {
|
||||
'monthly': 'Maaned',
|
||||
'monthly': 'Måned',
|
||||
'quarterly': 'Kvartal',
|
||||
'yearly': 'Aar',
|
||||
'yearly': 'År',
|
||||
'one_time': 'Engang'
|
||||
};
|
||||
return map[interval] || interval || '-';
|
||||
@ -782,7 +782,7 @@ async function createProduct() {
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
alert('Navn er paakraevet');
|
||||
alert('Navn er påkrævet');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -810,7 +810,7 @@ function openGatewaySearchModal(autoSearch = false) {
|
||||
document.getElementById('gatewayModalMinPrice').value = '';
|
||||
document.getElementById('gatewayModalMaxPrice').value = '';
|
||||
document.getElementById('gatewayModalInStock').checked = false;
|
||||
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Soeg efter produkter for at importere.</div>';
|
||||
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>';
|
||||
new bootstrap.Modal(document.getElementById('gatewaySearchModal')).show();
|
||||
if (autoSearch) {
|
||||
searchGatewayProducts(true);
|
||||
@ -836,7 +836,7 @@ async function searchGatewayProducts(fromModal = false) {
|
||||
const manufacturer = manufacturerInput ? manufacturerInput.value.trim() : '';
|
||||
const inStock = inStockInput ? inStockInput.checked : false;
|
||||
if (!q && !supplier) {
|
||||
alert('Angiv soegeord eller supplier code');
|
||||
alert('Angiv søgeord eller leverandørkode');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -851,14 +851,14 @@ async function searchGatewayProducts(fromModal = false) {
|
||||
|
||||
const resultsContainer = document.getElementById('gatewayResults');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>Soege...</div>';
|
||||
resultsContainer.innerHTML = '<div class="text-center text-muted py-4"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || 'Gateway soegning fejlede');
|
||||
throw new Error(error || 'Gateway-søgning fejlede');
|
||||
}
|
||||
const data = await res.json();
|
||||
const products = Array.isArray(data.products) ? data.products : [];
|
||||
@ -866,7 +866,7 @@ async function searchGatewayProducts(fromModal = false) {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved soegning'}</div>`;
|
||||
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved søgning'}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1041,7 +1041,7 @@ function renderGatewayResults() {
|
||||
</select>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" ${page <= 1 ? 'disabled' : ''} onclick="goGatewayPage(-1)">Forrige</button>
|
||||
<button class="btn btn-outline-secondary" ${page >= totalPages ? 'disabled' : ''} onclick="goGatewayPage(1)">Naeste</button>
|
||||
<button class="btn btn-outline-secondary" ${page >= totalPages ? 'disabled' : ''} onclick="goGatewayPage(1)">Næste</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1132,7 +1132,7 @@ function importGatewayProductFromAttr(button) {
|
||||
const product = JSON.parse(decodeURIComponent(payload));
|
||||
importGatewayProduct(product);
|
||||
} catch (e) {
|
||||
alert('Kunne ikke laese produktdata');
|
||||
alert('Kunne ikke læse produktdata');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -285,7 +285,7 @@ async def get_setting(key: str):
|
||||
seed_query,
|
||||
(
|
||||
"case_types",
|
||||
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
|
||||
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
|
||||
"system",
|
||||
"Sags-typer",
|
||||
"json",
|
||||
|
||||
@ -228,7 +228,7 @@
|
||||
lastError = errMsg;
|
||||
} catch (err) {
|
||||
attempts.push(`${url} -> ERR`);
|
||||
lastError = err.message || 'Netvaerksfejl';
|
||||
lastError = err.message || 'Netværksfejl';
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,7 +335,7 @@
|
||||
lastError = errMsg;
|
||||
} catch (err) {
|
||||
attempts.push(`${url} -> ERR`);
|
||||
lastError = err.message || 'Netvaerksfejl';
|
||||
lastError = err.message || 'Netværksfejl';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -635,7 +635,7 @@
|
||||
<select class="form-select" id="taskTemplateSourceFilter" onchange="loadTaskTemplates()">
|
||||
<option value="all">Alle</option>
|
||||
<option value="company">Firma</option>
|
||||
<option value="global">Faelles</option>
|
||||
<option value="global">Fælles</option>
|
||||
<option value="internal">Intern</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -647,7 +647,7 @@
|
||||
<option value="offboarding">Offboarding</option>
|
||||
<option value="simkort">Mobil / SIM-kort</option>
|
||||
<option value="hardwarebestilling">Hardwarebestilling</option>
|
||||
<option value="brugerandring">Brugeraendring</option>
|
||||
<option value="brugerandring">Brugerændring</option>
|
||||
<option value="andet">Andet</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -692,11 +692,11 @@
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0" id="taskTemplateItemsTitle">Template-opgaver</h6>
|
||||
<small class="text-muted" id="taskTemplateItemsSubtitle">Vaelg en template for at redigere items</small>
|
||||
<small class="text-muted" id="taskTemplateItemsSubtitle">Vælg en template for at redigere elementer</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" id="taskTemplateAddItemBtn" onclick="openTaskTemplateItemModal()" disabled>
|
||||
<i class="bi bi-plus-lg me-1"></i>Tilfoej
|
||||
<i class="bi bi-plus-lg me-1"></i>Tilføj
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="taskTemplateBulkBuilderBtn" onclick="openTaskTemplateBulkBuilderModal()" disabled>
|
||||
<i class="bi bi-magic me-1"></i>Builder
|
||||
@ -1078,7 +1078,7 @@
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
|
||||
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
|
||||
<small class="text-muted">Overvåger, om alle arkiverede tickets er synkroniseret (kildeantal mod lokal database)</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
|
||||
@ -1124,7 +1124,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
|
||||
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
|
||||
<small class="text-muted" id="archivedStatusHint">Polling er aktiv, når Sync-fanen er åben.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2114,7 +2114,7 @@ let sagTestReportsCache = [];
|
||||
|
||||
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
|
||||
{ label: 'Haster', text: 'Haster', multiplier: 3 },
|
||||
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
|
||||
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
|
||||
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
|
||||
];
|
||||
|
||||
@ -2600,9 +2600,9 @@ function renderDriftConnectors() {
|
||||
<label class="form-label fw-semibold">Ignorer varetekster</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilføj</button>
|
||||
</div>
|
||||
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
|
||||
<div class="form-text">Matcher på varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
|
||||
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2648,7 +2648,7 @@ async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese indstillinger'));
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse indstillinger'));
|
||||
}
|
||||
const payload = await response.json();
|
||||
allSettings = Array.isArray(payload) ? payload : [];
|
||||
@ -2744,11 +2744,11 @@ function addTimeMultiplierPreset() {
|
||||
const multiplier = Number(multiplierInput.value || 0);
|
||||
|
||||
if (!label) {
|
||||
showNotification('Navn er paakraevet', 'error');
|
||||
showNotification('Navn er påkrævet', 'error');
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(multiplier) || multiplier <= 0) {
|
||||
showNotification('Multiplier skal vaere stoerre end 0', 'error');
|
||||
showNotification('Multiplikatoren skal være større end 0', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4111,7 +4111,7 @@ async function loadAdminUsers() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/users');
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese brugere'));
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse brugere'));
|
||||
}
|
||||
usersCache = await response.json();
|
||||
displayUsers(usersCache);
|
||||
@ -4119,7 +4119,7 @@ async function loadAdminUsers() {
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlaese brugere')}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlæse brugere')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6274,7 +6274,7 @@ async function loadPipelineStages() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/pipeline/stages');
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese pipeline stages'));
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse pipeline-trin'));
|
||||
}
|
||||
const payload = await response.json();
|
||||
const stages = Array.isArray(payload) ? payload : [];
|
||||
@ -6698,14 +6698,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<option value="offboarding">Offboarding</option>
|
||||
<option value="simkort">Mobil / SIM-kort</option>
|
||||
<option value="hardwarebestilling">Hardwarebestilling</option>
|
||||
<option value="brugerandring">Brugeraendring</option>
|
||||
<option value="brugerandring">Brugerændring</option>
|
||||
<option value="andet" selected>Andet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Firma (kun firma-type)</label>
|
||||
<select id="taskTemplateCompany" class="form-select">
|
||||
<option value="">Vaelg firma...</option>
|
||||
<option value="">Vælg firma...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -6728,7 +6728,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilfoej item</h5>
|
||||
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilføj element</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -6849,21 +6849,21 @@ const TASK_TEMPLATE_BUILDER_PRESETS = {
|
||||
onboarding: [
|
||||
'task|0|Velkomstmail til bruger|Send velkomstmail med kontaktinfo og forventet plan',
|
||||
'task|0|Bestil hardware|Bestil standard udstyr til ny medarbejder',
|
||||
'task|1|Klargoer kontoer og licenser|Opsaet M365, VPN, sikkerhedsprofil og grupper',
|
||||
'task|2|Planlaeg introduktionsmoede|Book intro med leder og support',
|
||||
'subcase|0|Onboarding undersag: adgange|Opfoelgning paa adgangsrequests og godkendelser'
|
||||
'task|1|Klargør konti og licenser|Opsæt M365, VPN, sikkerhedsprofil og grupper',
|
||||
'task|2|Planlæg introduktionsmøde|Book introduktion med leder og support',
|
||||
'subcase|0|Onboarding-undersag: adgange|Opfølgning på adgangsanmodninger og godkendelser'
|
||||
],
|
||||
offboarding: [
|
||||
'task|0|Bekraeft fratraedelsesdato|Afstem sidste arbejdsdag med leder/HR',
|
||||
'task|0|Bekræft fratrædelsesdato|Afstem sidste arbejdsdag med leder/HR',
|
||||
'task|0|Luk adgange|Deaktiver M365, VPN og eksterne konti',
|
||||
'task|1|Indsaml udstyr|Koordiner retur af laptop, mobil og noegler',
|
||||
'task|1|Indsaml udstyr|Koordinér returnering af laptop, mobil og nøgler',
|
||||
'subcase|0|Offboarding undersag: dokumentation|Saml audit-noter og afslutningsdokumentation'
|
||||
],
|
||||
hardware: [
|
||||
'task|0|Afdaek behov|Afklar krav til model, tilbehoer og levering',
|
||||
'task|0|Indhent pris|Kontroller pris og leveringstid hos leverandoer',
|
||||
'task|0|Afdæk behov|Afklar krav til model, tilbehør og levering',
|
||||
'task|0|Indhent pris|Kontrollér pris og leveringstid hos leverandør',
|
||||
'task|1|Bestil hardware|Placer ordren og registrer ordrenummer',
|
||||
'task|3|Levering og klargoering|Klargoer enhed og informer bruger'
|
||||
'task|3|Levering og klargøring|Klargør enhed, og informér brugeren'
|
||||
]
|
||||
};
|
||||
|
||||
@ -6874,7 +6874,7 @@ function getTaskTemplateBuilderModal() {
|
||||
|
||||
function openTaskTemplateBulkBuilderModal() {
|
||||
if (!selectedTaskTemplateId) {
|
||||
alert('Vaelg en template foerst');
|
||||
alert('Vælg en template først');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -6964,7 +6964,7 @@ function parseTaskTemplateBuilderLines(rawText, defaults = {}) {
|
||||
|
||||
async function importTaskTemplateBulkItems() {
|
||||
if (!selectedTaskTemplateId) {
|
||||
alert('Vaelg en template foerst');
|
||||
alert('Vælg en template først');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7040,7 +7040,7 @@ function taskTemplateCategoryLabel(category) {
|
||||
offboarding: 'Offboarding',
|
||||
simkort: 'Mobil / SIM-kort',
|
||||
hardwarebestilling: 'Hardwarebestilling',
|
||||
brugerandring: 'Brugeraendring',
|
||||
brugerandring: 'Brugerændring',
|
||||
andet: 'Andet'
|
||||
};
|
||||
return labels[category] || category || '-';
|
||||
@ -7063,7 +7063,7 @@ async function loadTaskTemplateCustomers() {
|
||||
if (!filterSelect || !modalSelect) return;
|
||||
|
||||
filterSelect.innerHTML = '<option value="">Alle kunder</option>';
|
||||
modalSelect.innerHTML = '<option value="">Vaelg firma...</option>';
|
||||
modalSelect.innerHTML = '<option value="">Vælg firma...</option>';
|
||||
|
||||
customers.forEach((customer) => {
|
||||
const label = `${customer.name} (#${customer.id})`;
|
||||
@ -7126,7 +7126,7 @@ async function loadTaskTemplates() {
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading task templates:', error);
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlaesning')}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlæsning')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7173,12 +7173,12 @@ async function saveTaskTemplateSettings() {
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
alert('Navn er paakraevet');
|
||||
alert('Navn er påkrævet');
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateType === 'company' && !payload.customer_id) {
|
||||
alert('Vaelg firma for firma-template');
|
||||
alert('Vælg firma til firmatemplate');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7215,7 +7215,7 @@ async function deactivateTaskTemplateSettings(templateId) {
|
||||
if (selectedTaskTemplateId === templateId) {
|
||||
selectedTaskTemplateId = null;
|
||||
document.getElementById('taskTemplateItemsTitle').textContent = 'Template-opgaver';
|
||||
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vaelg en template for at redigere items';
|
||||
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vælg en template for at redigere elementer';
|
||||
document.getElementById('taskTemplateItemsList').innerHTML = '<div class="list-group-item text-muted small">Ingen template valgt.</div>';
|
||||
document.getElementById('taskTemplateAddItemBtn').disabled = true;
|
||||
document.getElementById('taskTemplateBulkBuilderBtn').disabled = true;
|
||||
@ -7295,13 +7295,13 @@ async function loadTaskTemplateItems(templateId) {
|
||||
|
||||
function openTaskTemplateItemModal() {
|
||||
if (!selectedTaskTemplateId) {
|
||||
alert('Vaelg en template foerst');
|
||||
alert('Vælg en template først');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('taskTemplateItemForm').reset();
|
||||
document.getElementById('taskTemplateItemId').value = '';
|
||||
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilfoej item';
|
||||
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilføj element';
|
||||
document.getElementById('taskTemplateItemRequired').checked = true;
|
||||
document.getElementById('taskTemplateItemActive').checked = true;
|
||||
|
||||
@ -7329,7 +7329,7 @@ function editTaskTemplateItem(item) {
|
||||
|
||||
async function saveTaskTemplateItem() {
|
||||
if (!selectedTaskTemplateId) {
|
||||
alert('Vaelg en template foerst');
|
||||
alert('Vælg en template først');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7348,7 +7348,7 @@ async function saveTaskTemplateItem() {
|
||||
};
|
||||
|
||||
if (!payload.title) {
|
||||
alert('Titel er paakraevet');
|
||||
alert('Titel er påkrævet');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
--frame-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
|
||||
--border-radius: 12px;
|
||||
--bottom-bar-height: 50px;
|
||||
--bottom-bar-expanded-height: 50vh;
|
||||
--bottom-bar-expanded-height: 68vh;
|
||||
--bottom-bar-zindex: 1030;
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
body.bottom-bar-visible.bottom-bar-expanded {
|
||||
padding-bottom: calc(var(--bottom-bar-height) + 52vh);
|
||||
padding-bottom: calc(var(--bottom-bar-height) + var(--bottom-bar-expanded-height));
|
||||
}
|
||||
|
||||
.global-bottom-bar {
|
||||
@ -381,7 +381,7 @@
|
||||
}
|
||||
|
||||
.global-bottom-bar.is-expanded .bb-sheet-panel {
|
||||
max-height: min(52vh, 460px);
|
||||
max-height: min(var(--bottom-bar-expanded-height), 590px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@ -390,10 +390,10 @@
|
||||
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
max-height: min(52vh, 420px);
|
||||
height: min(52vh, 420px);
|
||||
flex-direction: row;
|
||||
min-height: 320px;
|
||||
max-height: min(var(--bottom-bar-expanded-height), 540px);
|
||||
height: min(var(--bottom-bar-expanded-height), 540px);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
margin-top: 0.5rem;
|
||||
@ -403,15 +403,18 @@
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-side-tabs {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.09);
|
||||
background: rgba(var(--text-primary-rgb), 0.025);
|
||||
padding: 0.55rem 0.65rem;
|
||||
width: 176px;
|
||||
flex: 0 0 176px;
|
||||
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||
border-bottom: 0;
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
|
||||
padding: 0.85rem 0.65rem;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
min-height: auto;
|
||||
overflow-x: auto;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.global-bottom-bar .bb-side-tabs::-webkit-scrollbar { display: none; }
|
||||
@ -421,10 +424,10 @@
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 9px;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
padding: 0.45rem 0.7rem;
|
||||
padding: 0.62rem 0.7rem;
|
||||
line-height: 1.3;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
@ -432,6 +435,7 @@
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.global-bottom-bar .bb-tab-btn i {
|
||||
font-size: 1rem;
|
||||
@ -475,48 +479,58 @@
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-tab-content {
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
padding: 1rem 1.15rem 1.1rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-tab-title {
|
||||
font-size: 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-tab-heading { flex: 0 0 auto; padding-bottom: 0.75rem; margin-bottom: 0.75rem; border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08); }
|
||||
.global-bottom-bar .bb-tab-description { color: var(--text-secondary); font-size: 0.78rem; line-height: 1.35; }
|
||||
|
||||
.global-bottom-bar .bb-tab-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-tab-list li {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: var(--accent-light);
|
||||
border-radius: 6px 8px 8px 6px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||
background: color-mix(in srgb, var(--bg-card) 96%, var(--text-primary));
|
||||
border-radius: 11px;
|
||||
padding: 0.68rem 0.78rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
box-shadow: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.global-bottom-bar .bb-tab-list li:hover {
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.global-bottom-bar .bb-tab-content .bb-tab-list { overflow-y: auto; min-height: 0; padding-right: 0.25rem; }
|
||||
.global-bottom-bar .bb-task-actions { display: flex; justify-content: flex-end; margin: 0 0 0.75rem !important; width: 100%; }
|
||||
.global-bottom-bar .bb-task-actions .btn { width: auto !important; box-shadow: none !important; }
|
||||
.global-bottom-bar .bb-boss-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -535,7 +549,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.7rem;
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
border-left: 4px solid #f59e0b;
|
||||
@ -558,7 +572,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
padding: 0.1rem 0 0.45rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.global-bottom-bar .bb-message-threads::-webkit-scrollbar {
|
||||
@ -600,7 +614,8 @@
|
||||
color: #fff;
|
||||
}
|
||||
.global-bottom-bar .bb-messages-list {
|
||||
max-height: min(26vh, 240px);
|
||||
max-height: none;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
@ -608,10 +623,16 @@
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.global-bottom-bar #bbTabInnerContent > * { width: 100%; min-width: 0; }
|
||||
.global-bottom-bar .bb-panel-overview,
|
||||
.global-bottom-bar .bb-panel-tasks,
|
||||
.global-bottom-bar .bb-panel-boss { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); align-content: start; }
|
||||
.global-bottom-bar .bb-panel-timer { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-content: start; }
|
||||
.global-bottom-bar .bb-notes-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -621,23 +642,44 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.global-bottom-bar .bb-notes-editor {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.global-bottom-bar .bb-notes-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
.global-bottom-bar .bb-messages-composer {
|
||||
border-top: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||
padding-top: 0.55rem;
|
||||
border: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.global-bottom-bar .bb-messages-layout {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.global-bottom-bar .bb-messages-list li { border: 0; background: transparent; padding: 0.2rem 0.1rem; }
|
||||
.global-bottom-bar .bb-messages-list li:hover { transform: none; box-shadow: none; }
|
||||
.global-bottom-bar .bb-notes-editor > li { border: 0; background: transparent; padding: 0; }
|
||||
.global-bottom-bar .bb-notes-editor > li > .border { border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent) !important; border-radius: 12px !important; background: color-mix(in srgb, var(--accent) 4%, var(--bg-card)) !important; padding: 0.8rem !important; margin: 0 !important; }
|
||||
.global-bottom-bar .bb-boss-kpis { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); }
|
||||
.global-bottom-bar .bb-boss-kpis > [class*="col-"] { width: auto; }
|
||||
.global-bottom-bar .bb-panel-timer li { border-color: rgba(25, 135, 84, 0.18); background: rgba(25, 135, 84, 0.045); }
|
||||
.global-bottom-bar .bb-timer-case-link { cursor: pointer; }
|
||||
.global-bottom-bar .bb-timer-case-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
|
||||
.global-bottom-bar .bb-timer-work-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.3rem; overflow-y: auto; min-height: 0; }
|
||||
.global-bottom-bar .bb-timer-work-card { display: flex; align-items: center; justify-content: space-between; gap: 0.55rem; min-height: 38px; padding: 0.28rem 0.45rem; border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 8px; background: var(--bg-card); cursor: pointer; font-size: 0.72rem; }
|
||||
.global-bottom-bar .bb-timer-work-main { display: flex; align-items: center; gap: 0.45rem; min-width: 0; flex: 1 1 auto; white-space: nowrap; overflow: hidden; }
|
||||
.global-bottom-bar .bb-timer-work-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.76rem; }
|
||||
.global-bottom-bar .bb-timer-work-meta { color: var(--text-secondary); flex: 0 0 auto; font-size: 0.68rem; white-space: nowrap; }
|
||||
.global-bottom-bar .bb-timer-work-actions { display: flex; gap: 0.25rem; flex: 0 0 auto; }
|
||||
.global-bottom-bar .bb-timer-work-card .btn { --bs-btn-padding-y: 0.18rem; --bs-btn-padding-x: 0.4rem; --bs-btn-font-size: 0.68rem; }
|
||||
.global-bottom-bar .bb-panel-tasks li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.global-bottom-bar .bb-panel-empty { min-height: 140px; display: grid; place-items: center; text-align: center; color: var(--text-secondary); }
|
||||
.global-bottom-bar .bb-panel-empty i { display: block; font-size: 1.8rem; color: color-mix(in srgb, var(--accent) 55%, var(--text-secondary)); margin-bottom: 0.35rem; }
|
||||
.global-bottom-bar .bb-live-indicator { width: 0.58rem; height: 0.58rem; border-radius: 50%; background: #198754; box-shadow: 0 0 0 5px rgba(25, 135, 84, 0.12); display: inline-block; margin-right: 0.55rem; }
|
||||
.global-bottom-bar .bb-detail-line {
|
||||
min-height: 28px;
|
||||
padding: 0.2rem 0.2rem 0;
|
||||
@ -659,6 +701,34 @@
|
||||
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
#bbSwitchCaseModal .modal-dialog { max-width: 1080px; }
|
||||
#bbSwitchCaseModal .modal-body { padding: 1rem 1.15rem 1.2rem; }
|
||||
#bbSwitchCaseModal .bb-switch-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.8rem; margin-bottom: 1rem; border: 1px solid rgba(var(--text-primary-rgb), 0.08); border-radius: 12px; background: color-mix(in srgb, var(--accent) 4%, var(--bg-card)); }
|
||||
#bbSwitchCaseModal .bb-switch-search { max-width: 360px; }
|
||||
#bbSwitchCaseModal .bb-switch-section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); }
|
||||
#bbSwitchCaseModal .bb-switch-timers { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; margin-bottom: 1rem; }
|
||||
#bbSwitchCaseModal .bb-switch-timer, #bbSwitchCaseModal .bb-switch-case { border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 11px; background: var(--bg-card); }
|
||||
#bbSwitchCaseModal .bb-switch-timer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.7rem; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-info { display: flex; align-items: center; gap: 0.55rem; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-info-title { min-width: 120px; max-width: 34%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-info-meta { color: var(--text-secondary); font-size: 0.72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-status { font-size: 0.66rem; font-weight: 600; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-pending { width: 100%; min-height: 38px; padding: 0.28rem 0.4rem; gap: 0.35rem; border-left: 2px solid var(--bs-info); font-size: 0.72rem; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-main { display: flex; align-items: center; gap: 0.35rem; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-title { color: var(--text-primary); font-size: 0.72rem; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-meta { flex: 0 0 auto; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-pending .bb-switch-case-actions { gap: 0.25rem; }
|
||||
#bbSwitchCaseModal .bb-switch-timer-pending .btn { --bs-btn-padding-y: 0.18rem; --bs-btn-padding-x: 0.38rem; --bs-btn-font-size: 0.68rem; }
|
||||
#bbSwitchCaseModal .bb-switch-cases { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
|
||||
#bbSwitchCaseModal .bb-switch-case { display: flex; align-items: center; gap: 0.75rem; padding: 0.72rem; min-width: 0; }
|
||||
#bbSwitchCaseModal .bb-switch-case-main { flex: 1 1 auto; min-width: 0; }
|
||||
#bbSwitchCaseModal .bb-switch-case-title { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-case-meta { color: var(--text-secondary); font-size: 0.75rem; margin-top: 0.15rem; }
|
||||
#bbSwitchCaseModal .bb-switch-case-actions { display: flex; gap: 0.4rem; flex: 0 0 auto; }
|
||||
#bbSwitchCaseModal .bb-switch-case-actions .btn { white-space: nowrap; }
|
||||
#bbSwitchCaseModal .bb-switch-empty { grid-column: 1 / -1; padding: 1.4rem; text-align: center; color: var(--text-secondary); border: 1px dashed rgba(var(--text-primary-rgb), 0.14); border-radius: 11px; }
|
||||
#bbSwitchCaseModal .bb-convert-panel { padding: 0.9rem; border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--accent) 6%, var(--bg-card)); }
|
||||
|
||||
#bbSwitchCaseModal .list-group-item {
|
||||
border-color: rgba(var(--text-primary-rgb), 0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
@ -690,6 +760,7 @@
|
||||
|
||||
.global-bottom-bar .bb-sheet-inner {
|
||||
min-height: 240px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.global-bottom-bar.is-expanded .bb-header {
|
||||
@ -705,13 +776,22 @@
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-side-tabs {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.global-bottom-bar .bb-tab-btn { width: auto; }
|
||||
.global-bottom-bar .bb-notes-layout { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
.global-bottom-bar .bb-boss-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||
.global-bottom-bar .bb-support-queue-card { align-items: stretch; flex-direction: column; }
|
||||
.global-bottom-bar .bb-support-queue-actions { width: 100%; }
|
||||
.global-bottom-bar .bb-support-queue-actions select { width: auto; flex: 1 1 auto; }
|
||||
#bbSwitchCaseModal .bb-switch-toolbar { align-items: stretch; flex-direction: column; }
|
||||
#bbSwitchCaseModal .bb-switch-search { max-width: none; }
|
||||
#bbSwitchCaseModal .bb-switch-cases { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.navbar {
|
||||
@ -1374,7 +1454,10 @@
|
||||
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-diagram-3"></i> Sagsfordeling</button>
|
||||
</div>
|
||||
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
|
||||
<div 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 class="bb-tab-heading">
|
||||
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
|
||||
<div id="bbTabDescription" class="bb-tab-description">Det vigtigste samlet ét sted.</div>
|
||||
</div>
|
||||
<div id="bbTabInnerContent">
|
||||
<ul id="bbTabList" class="bb-tab-list">
|
||||
<li>Venter på data...</li>
|
||||
@ -1386,14 +1469,17 @@
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg modal-dialog-bottom">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-xl modal-dialog-bottom">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="bbSwitchCaseStatus" class="small text-muted mb-3">Henter data...</div>
|
||||
<div class="bb-switch-toolbar">
|
||||
<div id="bbSwitchCaseStatus" class="small text-muted">Henter data...</div>
|
||||
<div class="input-group input-group-sm bb-switch-search"><span class="input-group-text"><i class="bi bi-search"></i></span><input id="bbSwitchCaseSearch" type="search" class="form-control" placeholder="Søg efter sag eller nummer" aria-label="Søg efter sag"></div>
|
||||
</div>
|
||||
|
||||
<div id="bbSwitchTimerActions" class="mb-3 d-none">
|
||||
<div class="fw-semibold mb-2">Aktiv timer fundet</div>
|
||||
@ -1404,20 +1490,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<h6 class="mb-2">Dine aktive/pausede timere</h6>
|
||||
<div id="bbSwitchTimersList" class="list-group small">
|
||||
<div class="list-group-item text-muted">Henter timere...</div>
|
||||
</div>
|
||||
<div id="bbConvertTimePanel" class="bb-convert-panel d-none mb-3">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div><div class="fw-bold">Konverter afsluttet tid</div><div id="bbConvertTimeName" class="small text-muted"></div></div>
|
||||
<button type="button" class="btn-close" data-bb-cancel-convert aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<h6 class="mb-2">Seneste sager</h6>
|
||||
<div id="bbSwitchRecentCasesList" class="list-group small">
|
||||
<div class="list-group-item text-muted">Henter sager...</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-lg-3"><label class="form-label small">Arbejdstype</label><select id="bbConvertWorkType" class="form-select form-select-sm"><option value="support">Support</option><option value="troubleshooting">Fejlsøgning</option><option value="development">Udvikling</option><option value="maintenance">Vedligehold</option><option value="on_site">Kørsel / On-site</option><option value="meeting">Møde</option><option value="other">Andet</option></select></div>
|
||||
<div class="col-6 col-lg-3"><label class="form-label small">Fakturerbare minutter</label><input id="bbConvertMinutes" type="number" min="0" step="1" class="form-control form-control-sm"></div>
|
||||
<div class="col-12 col-lg-4"><label class="form-label small">Afregning</label><select id="bbConvertBillingMethod" class="form-select form-select-sm"></select><div id="bbConvertRecommendation" class="form-text"></div></div>
|
||||
<div class="col-12 col-lg-2 d-flex align-items-end"><button id="bbConvertSubmit" type="button" class="btn btn-primary btn-sm w-100"><i class="bi bi-check2-circle me-1"></i>Konverter tid</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="bb-switch-section-title mb-2">Timere på dine sager</h6>
|
||||
<div id="bbSwitchTimersList" class="bb-switch-timers small"><div class="bb-switch-empty">Henter timere...</div></div>
|
||||
<h6 class="bb-switch-section-title mb-2">Seneste sager</h6>
|
||||
<div id="bbSwitchRecentCasesList" class="bb-switch-cases small"><div class="bb-switch-empty">Henter sager...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1489,7 +1578,7 @@ if (bmcOriginalFetch) {
|
||||
<script src="/static/js/telefoni.js?v=2.4"></script>
|
||||
<script src="/static/js/sms.js?v=1.1"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.4"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.44"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.60"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||
@ -2079,6 +2168,9 @@ if (bmcOriginalFetch) {
|
||||
const buzzwordHtml = buzzwords.length
|
||||
? ` • <i class="bi bi-lightbulb ms-1"></i> ${buzzwords.map(word => escapeHtml(word)).join(', ')}`
|
||||
: '';
|
||||
const archivedHtml = item.is_archived
|
||||
? ' • <span class="badge bg-secondary">Arkiveret</span>'
|
||||
: '';
|
||||
return `
|
||||
<div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;">
|
||||
<div>
|
||||
@ -2087,6 +2179,7 @@ if (bmcOriginalFetch) {
|
||||
<i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')}
|
||||
${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
|
||||
${buzzwordHtml}
|
||||
${archivedHtml}
|
||||
</div>
|
||||
</div>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
<tbody id="subscriptionsBody">
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted py-5">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Indlæser...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -539,7 +539,7 @@ async function loadSubscriptions() {
|
||||
document.getElementById('subscriptionsBody').innerHTML = `
|
||||
<tr><td colspan="9" class="text-center text-danger py-5">
|
||||
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
||||
<p>Fejl ved indlaesning</p>
|
||||
<p>Fejl ved indlæsning</p>
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
@ -820,9 +820,9 @@ function formatInterval(interval) {
|
||||
const map = {
|
||||
'daily': 'Daglig',
|
||||
'biweekly': '14-dage',
|
||||
'monthly': 'Maaned',
|
||||
'monthly': 'Måned',
|
||||
'quarterly': 'Kvartal',
|
||||
'yearly': 'Aar'
|
||||
'yearly': 'År'
|
||||
};
|
||||
return map[interval] || interval || '-';
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ class WorkType(str, Enum):
|
||||
"""Type af arbejde"""
|
||||
SUPPORT = "support"
|
||||
DEVELOPMENT = "development"
|
||||
MAINTENANCE = "maintenance"
|
||||
TROUBLESHOOTING = "troubleshooting"
|
||||
ON_SITE = "on_site"
|
||||
MEETING = "meeting"
|
||||
|
||||
@ -626,7 +626,7 @@
|
||||
|
||||
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
|
||||
{ label: 'Haster', text: 'Haster', multiplier: 3 },
|
||||
{ label: 'Avanceret netvaerk', text: 'Avanceret netvaerk', multiplier: 2 },
|
||||
{ label: 'Avanceret netværk', text: 'Avanceret netværk', multiplier: 2 },
|
||||
{ label: 'Haster + ava. network', text: 'Haster + ava. network', multiplier: 6 }
|
||||
];
|
||||
|
||||
@ -725,6 +725,7 @@
|
||||
<option value="support" selected>Support</option>
|
||||
<option value="troubleshooting">Fejlsøgning</option>
|
||||
<option value="development">Udvikling</option>
|
||||
<option value="maintenance">Vedligehold</option>
|
||||
<option value="on_site">Kørsel / On-site</option>
|
||||
<option value="meeting">Møde</option>
|
||||
<option value="other">Andet</option>
|
||||
|
||||
@ -372,6 +372,7 @@
|
||||
<option value="support">Support</option>
|
||||
<option value="troubleshooting">Fejlsøgning</option>
|
||||
<option value="development">Udvikling</option>
|
||||
<option value="maintenance">Vedligehold</option>
|
||||
<option value="on_site">Kørsel / On-site</option>
|
||||
<option value="meeting">Møde</option>
|
||||
<option value="other">Andet</option>
|
||||
|
||||
@ -116,7 +116,7 @@ class OrderService:
|
||||
WHERE t.customer_id = %s
|
||||
AND t.status = 'approved'
|
||||
AND t.billable = true
|
||||
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card')
|
||||
AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card', 'subscription', 'fixed_price', 'internal', 'non_billable', 'warranty')
|
||||
ORDER BY COALESCE(c.id, s.id), t.worked_date
|
||||
"""
|
||||
approved_times = execute_query(query, (customer_id,))
|
||||
|
||||
@ -162,6 +162,48 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
|
||||
"""Return valid settlement choices and a deterministic recommendation."""
|
||||
customer = execute_query_single(
|
||||
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
) or {}
|
||||
hub_customer_id = customer.get("hub_customer_id")
|
||||
cards = []
|
||||
agreements = []
|
||||
if hub_customer_id:
|
||||
cards = execute_query(
|
||||
"""
|
||||
SELECT id, card_number, remaining_hours, rounding_minutes, expires_at
|
||||
FROM tticket_prepaid_cards
|
||||
WHERE customer_id = %s AND status = 'active' AND remaining_hours > 0
|
||||
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
|
||||
ORDER BY expires_at ASC NULLS LAST, created_at ASC
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
) or []
|
||||
agreements = execute_query(
|
||||
"""
|
||||
SELECT id, agreement_number, monthly_hours
|
||||
FROM customer_fixed_price_agreements
|
||||
WHERE customer_id = %s AND status = 'active'
|
||||
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
|
||||
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
) or []
|
||||
|
||||
if cards:
|
||||
recommendation = {"method": "prepaid", "prepaid_card_id": cards[0]["id"], "reason": "Kunden har et aktivt klippekort"}
|
||||
elif agreements:
|
||||
recommendation = {"method": "subscription", "fixed_price_agreement_id": agreements[0]["id"], "reason": "Kunden har en aktiv aftale"}
|
||||
else:
|
||||
recommendation = {"method": "invoice", "reason": "Der er ingen aktiv inkluderet aftale"}
|
||||
|
||||
return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements}
|
||||
|
||||
|
||||
def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]:
|
||||
"""Resolve tmodule customer_id for a case (tmodule_times FK target)."""
|
||||
try:
|
||||
@ -1811,7 +1853,10 @@ async def list_time_entries(
|
||||
status: Optional[str] = None,
|
||||
customer_id: Optional[int] = None,
|
||||
user_name: Optional[str] = None,
|
||||
search: Optional[str] = None
|
||||
search: Optional[str] = None,
|
||||
work_type: Optional[str] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
):
|
||||
"""
|
||||
Hent liste af tidsregistreringer med filtre.
|
||||
@ -1841,6 +1886,18 @@ async def list_time_entries(
|
||||
query += " AND t.user_name ILIKE %s"
|
||||
params.append(f"%{user_name}%")
|
||||
|
||||
if work_type:
|
||||
query += " AND COALESCE(t.work_type, 'support') = %s"
|
||||
params.append(work_type)
|
||||
|
||||
if start_date:
|
||||
query += " AND DATE(COALESCE(t.worked_date, t.created_at)) >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND DATE(COALESCE(t.worked_date, t.created_at)) <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
if search:
|
||||
query += """ AND (
|
||||
t.description ILIKE %s OR
|
||||
@ -1850,11 +1907,36 @@ async def list_time_entries(
|
||||
wildcard = f"%{search}%"
|
||||
params.extend([wildcard, wildcard, wildcard])
|
||||
|
||||
filtered_query = query
|
||||
filtered_params = list(params)
|
||||
query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s"
|
||||
params.extend([limit, offset])
|
||||
params.extend([min(max(limit, 1), 10000), max(offset, 0)])
|
||||
|
||||
times = execute_query(query, tuple(params))
|
||||
return {"times": times}
|
||||
summary = execute_query_single(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(*)::int AS total_entries,
|
||||
COALESCE(SUM(t.original_hours), 0)::numeric AS total_hours,
|
||||
COALESCE(SUM(CASE WHEN t.billable THEN COALESCE(t.approved_hours, t.original_hours, 0) ELSE 0 END), 0)::numeric AS billable_hours,
|
||||
COUNT(DISTINCT COALESCE(t.medarbejder_id::text, t.user_name))::int AS total_employees,
|
||||
COUNT(DISTINCT COALESCE(t.sag_id, t.case_id))::int AS total_cases
|
||||
FROM ({filtered_query}) AS t
|
||||
""",
|
||||
tuple(filtered_params),
|
||||
) or {}
|
||||
type_rows = execute_query(
|
||||
f"""
|
||||
SELECT COALESCE(t.work_type, 'support') AS work_type,
|
||||
COUNT(*)::int AS entries,
|
||||
COALESCE(SUM(t.original_hours), 0)::numeric AS hours
|
||||
FROM ({filtered_query}) AS t
|
||||
GROUP BY COALESCE(t.work_type, 'support')
|
||||
ORDER BY hours DESC
|
||||
""",
|
||||
tuple(filtered_params),
|
||||
) or []
|
||||
return {"times": times, "summary": summary, "by_work_type": type_rows}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing times: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@ -2239,6 +2321,7 @@ async def list_time_entries_v1(
|
||||
sag_id: int = Query(..., gt=0),
|
||||
day: Optional[date] = Query(None),
|
||||
medarbejder_id: Optional[int] = Query(None, gt=0),
|
||||
current_user: Optional[dict] = Depends(get_optional_user),
|
||||
):
|
||||
"""List tidsregistreringer for en sag med filtre til timeline UI."""
|
||||
try:
|
||||
@ -2255,13 +2338,31 @@ async def list_time_entries_v1(
|
||||
|
||||
where_sql = " AND ".join(clauses)
|
||||
query = f"""
|
||||
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
|
||||
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
|
||||
cust.name AS customer_name, primary_contact.contact_name,
|
||||
u.full_name AS employee_display_name, u.username AS employee_username,
|
||||
(t.medarbejder_id = %s) AS is_own_timer,
|
||||
CASE
|
||||
WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL THEN
|
||||
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0), 0)
|
||||
ELSE NULL
|
||||
END AS live_elapsed_seconds
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN customers cust ON cust.id = s.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
|
||||
FROM sag_kontakter sk
|
||||
JOIN contacts cont ON cont.id = sk.contact_id
|
||||
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
|
||||
ORDER BY sk.is_primary DESC, sk.id ASC
|
||||
LIMIT 1
|
||||
) primary_contact ON TRUE
|
||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||
WHERE {where_sql}
|
||||
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at) DESC, t.id DESC
|
||||
"""
|
||||
return execute_query(query, tuple(params))
|
||||
return execute_query(query, tuple([_resolve_current_user_id(current_user)] + params))
|
||||
except Exception as e:
|
||||
logger.error("❌ Error listing v1 time entries for sag %s: %s", sag_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to list time entries")
|
||||
@ -2303,35 +2404,14 @@ async def start_live_timer_v1(
|
||||
|
||||
paused_entry = None
|
||||
if existing:
|
||||
actual_minutes = _elapsed_minutes_excluding_pause(existing, now)
|
||||
rounded_minutes = _round_up_minutes(actual_minutes, existing.get("round_block_min") or 30)
|
||||
pause_total_seconds = _pause_total_seconds_at(existing, now)
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE tmodule_times
|
||||
SET slut_tid = %s,
|
||||
aktiv_timer = FALSE,
|
||||
paused_at = NULL,
|
||||
pause_total_seconds = %s,
|
||||
faktisk_tid_min = %s,
|
||||
fakturerbar_tid_min = CASE WHEN billable THEN %s ELSE 0 END,
|
||||
original_hours = GREATEST(%s::numeric / 60.0, 0.01),
|
||||
approved_hours = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
|
||||
rounded_to = CASE WHEN billable THEN (%s::numeric / 60.0) ELSE NULL END,
|
||||
entry_status = 'afventer',
|
||||
status = 'pending'
|
||||
SET aktiv_timer = FALSE,
|
||||
paused_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
now,
|
||||
pause_total_seconds,
|
||||
actual_minutes,
|
||||
rounded_minutes,
|
||||
actual_minutes,
|
||||
rounded_minutes,
|
||||
existing.get("round_block_min") or 30,
|
||||
existing["id"],
|
||||
)
|
||||
(now, existing["id"])
|
||||
)
|
||||
paused_entry = existing["id"]
|
||||
|
||||
@ -2353,13 +2433,13 @@ async def start_live_timer_v1(
|
||||
worked_date, user_name, status, billable,
|
||||
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
|
||||
entry_type, kilde, entry_status, medarbejder_id,
|
||||
aktiv_timer, round_block_min, ikke_placeret
|
||||
aktiv_timer, round_block_min, ikke_placeret, work_type
|
||||
) VALUES (
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s
|
||||
%s, %s, %s, %s
|
||||
) RETURNING *
|
||||
""",
|
||||
(
|
||||
@ -2382,6 +2462,7 @@ async def start_live_timer_v1(
|
||||
True,
|
||||
round_block_min,
|
||||
False,
|
||||
payload.get("work_type") or "support",
|
||||
)
|
||||
)
|
||||
|
||||
@ -2615,7 +2696,7 @@ async def resume_live_timer_v1(
|
||||
async def list_my_switchable_timers_v1(
|
||||
current_user: Optional[dict] = Depends(get_optional_user)
|
||||
):
|
||||
"""List authenticated user's currently active and paused timers for switch-case UI."""
|
||||
"""List authenticated user's active, paused and recently stopped timers for switch-case UI."""
|
||||
try:
|
||||
bruger_id = _resolve_current_user_id(current_user)
|
||||
if not bruger_id:
|
||||
@ -2623,8 +2704,24 @@ async def list_my_switchable_timers_v1(
|
||||
|
||||
active = execute_query(
|
||||
"""
|
||||
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
|
||||
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
|
||||
cust.name AS customer_name, primary_contact.contact_name,
|
||||
u.full_name AS employee_display_name, u.username AS employee_username,
|
||||
GREATEST(
|
||||
EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0),
|
||||
0
|
||||
) AS live_elapsed_seconds
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN customers cust ON cust.id = s.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
|
||||
FROM sag_kontakter sk
|
||||
JOIN contacts cont ON cont.id = sk.contact_id
|
||||
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
|
||||
ORDER BY sk.is_primary DESC, sk.id ASC
|
||||
LIMIT 1
|
||||
) primary_contact ON TRUE
|
||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||
WHERE t.medarbejder_id = %s
|
||||
AND t.slut_tid IS NULL
|
||||
@ -2637,8 +2734,13 @@ async def list_my_switchable_timers_v1(
|
||||
|
||||
paused = execute_query(
|
||||
"""
|
||||
SELECT t.*, u.full_name AS employee_display_name, u.username AS employee_username
|
||||
SELECT t.*, s.titel AS sag_navn, u.full_name AS employee_display_name, u.username AS employee_username,
|
||||
GREATEST(
|
||||
EXTRACT(EPOCH FROM (t.paused_at - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0),
|
||||
0
|
||||
) AS live_elapsed_seconds
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||
WHERE t.medarbejder_id = %s
|
||||
AND t.slut_tid IS NULL
|
||||
@ -2649,9 +2751,38 @@ async def list_my_switchable_timers_v1(
|
||||
(bruger_id,),
|
||||
)
|
||||
|
||||
stopped = execute_query(
|
||||
"""
|
||||
SELECT t.*, s.titel AS sag_navn, s.status AS case_status,
|
||||
cust.name AS customer_name, primary_contact.contact_name,
|
||||
u.full_name AS employee_display_name, u.username AS employee_username
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN customers cust ON cust.id = s.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT TRIM(CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, ''))) AS contact_name
|
||||
FROM sag_kontakter sk
|
||||
JOIN contacts cont ON cont.id = sk.contact_id
|
||||
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
|
||||
ORDER BY sk.is_primary DESC, sk.id ASC
|
||||
LIMIT 1
|
||||
) primary_contact ON TRUE
|
||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||
WHERE t.medarbejder_id = %s
|
||||
AND t.slut_tid IS NOT NULL
|
||||
AND t.aktiv_timer = FALSE
|
||||
AND COALESCE(t.entry_status, 'afventer') <> 'godkendt'
|
||||
AND COALESCE(t.status, 'pending') = 'pending'
|
||||
ORDER BY t.slut_tid DESC NULLS LAST, t.id DESC
|
||||
LIMIT 25
|
||||
""",
|
||||
(bruger_id,),
|
||||
)
|
||||
|
||||
return {
|
||||
"active": active or [],
|
||||
"paused": paused or [],
|
||||
"stopped": stopped or [],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
@ -2660,6 +2791,47 @@ async def list_my_switchable_timers_v1(
|
||||
raise HTTPException(status_code=500, detail="Failed to list switchable timers")
|
||||
|
||||
|
||||
@router.get("/time/team-status", tags=["Internal"])
|
||||
async def list_team_timer_status_v1(
|
||||
scope: str = Query("mine", pattern="^(mine|all)$"),
|
||||
current_user: Optional[dict] = Depends(get_optional_user),
|
||||
):
|
||||
"""Timer work queue for the bottom bar, optionally across all employees."""
|
||||
bruger_id = _resolve_current_user_id(current_user)
|
||||
if not bruger_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
employee_filter = "AND t.medarbejder_id = %s" if scope == "mine" else ""
|
||||
params = (bruger_id,) if scope == "mine" else ()
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT t.*, s.titel AS sag_navn,
|
||||
COALESCE(u.full_name, u.username, t.user_name, 'Ukendt medarbejder') AS employee_display_name,
|
||||
(t.medarbejder_id = %s) AS is_own_timer,
|
||||
CASE
|
||||
WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL THEN 'active'
|
||||
WHEN t.paused_at IS NOT NULL AND t.slut_tid IS NULL THEN 'paused'
|
||||
ELSE 'pending_conversion'
|
||||
END AS timer_state,
|
||||
CASE WHEN t.aktiv_timer = TRUE AND t.slut_tid IS NULL
|
||||
THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::bigint - COALESCE(t.pause_total_seconds, 0), 0)
|
||||
ELSE NULL END AS live_elapsed_seconds
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||
WHERE (
|
||||
(t.slut_tid IS NULL AND (t.aktiv_timer = TRUE OR t.paused_at IS NOT NULL))
|
||||
OR (t.slut_tid IS NOT NULL AND COALESCE(t.entry_status, 'afventer') <> 'godkendt' AND COALESCE(t.status, 'pending') = 'pending')
|
||||
)
|
||||
{employee_filter}
|
||||
ORDER BY CASE WHEN t.aktiv_timer THEN 0 WHEN t.slut_tid IS NULL THEN 1 ELSE 2 END,
|
||||
COALESCE(t.updated_at, t.created_at) DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
tuple([bruger_id] + list(params)),
|
||||
) or []
|
||||
return {"scope": scope, "rows": rows}
|
||||
|
||||
|
||||
@router.post("/time/manual", tags=["Internal"])
|
||||
async def create_manual_time_v1(
|
||||
payload: Dict[str, Any] = Body(...),
|
||||
@ -2723,7 +2895,7 @@ async def create_manual_time_v1(
|
||||
start_tid, slut_tid, faktisk_tid_min, fakturerbar_tid_min,
|
||||
entry_type, kilde, entry_status, medarbejder_id,
|
||||
aktiv_timer, round_block_min, ikke_placeret,
|
||||
approved_hours, rounded_to
|
||||
approved_hours, rounded_to, work_type
|
||||
) VALUES (
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s,
|
||||
@ -2731,7 +2903,7 @@ async def create_manual_time_v1(
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s,
|
||||
%s, %s
|
||||
%s, %s, %s
|
||||
) RETURNING *
|
||||
"""
|
||||
|
||||
@ -2761,6 +2933,7 @@ async def create_manual_time_v1(
|
||||
not_placed,
|
||||
(billable_minutes / 60.0) if billable else None,
|
||||
(round_block_min / 60.0) if billable else None,
|
||||
payload.get("work_type") or "support",
|
||||
)
|
||||
)
|
||||
return inserted[0] if inserted else None
|
||||
@ -2787,7 +2960,7 @@ async def patch_time_entry_v1(
|
||||
updates: Dict[str, Any] = {}
|
||||
allowed_direct = [
|
||||
"description", "entry_type", "kilde", "entry_status", "billable", "worked_date",
|
||||
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id"
|
||||
"fakturerbar_tid_min", "round_block_min", "ikke_placeret", "medarbejder_id", "work_type"
|
||||
]
|
||||
for key in allowed_direct:
|
||||
if key in payload:
|
||||
@ -2850,6 +3023,15 @@ async def patch_time_entry_v1(
|
||||
raise HTTPException(status_code=500, detail="Failed to patch time entry")
|
||||
|
||||
|
||||
@router.get("/time/{time_id}/settlement-options", tags=["Internal"])
|
||||
async def get_time_settlement_options_v1(time_id: int):
|
||||
"""Show valid invoice, prepaid and agreement choices before approval."""
|
||||
entry = execute_query_single("SELECT id, customer_id FROM tmodule_times WHERE id = %s", (time_id,))
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Time entry not found")
|
||||
return _settlement_options_for_customer(entry.get("customer_id"))
|
||||
|
||||
|
||||
@router.post("/time/{time_id}/approve", tags=["Internal"])
|
||||
async def approve_time_entry_v1(
|
||||
time_id: int,
|
||||
@ -2865,13 +3047,26 @@ async def approve_time_entry_v1(
|
||||
_assert_prepaid_entry_editable(entry)
|
||||
|
||||
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
|
||||
work_type = str(payload.get("work_type") or entry.get("work_type") or "support")
|
||||
if work_type not in {"support", "troubleshooting", "development", "maintenance", "on_site", "meeting", "other"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid work_type")
|
||||
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
|
||||
if entry_type == "ukendt":
|
||||
if not is_admin_approver:
|
||||
raise HTTPException(status_code=400, detail="entry_type is required before approval")
|
||||
logger.warning("⚠️ Admin approved time entry with ukendt type (time_id=%s)", time_id)
|
||||
|
||||
billable = bool(payload.get("fakturerbar", entry.get("billable", True)))
|
||||
options = _settlement_options_for_customer(entry.get("customer_id"))
|
||||
recommendation = options.get("recommended") or {"method": "invoice"}
|
||||
billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower()
|
||||
if billing_method == "fixed_price":
|
||||
billing_method = "subscription"
|
||||
if billing_method not in {"invoice", "prepaid", "subscription", "internal", "non_billable"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid billing_method")
|
||||
|
||||
prepaid_card_id = payload.get("prepaid_card_id") or recommendation.get("prepaid_card_id")
|
||||
agreement_id = payload.get("fixed_price_agreement_id") or recommendation.get("fixed_price_agreement_id")
|
||||
billable = billing_method in {"invoice", "prepaid"}
|
||||
billed_minutes = payload.get("fakturerbar_tid_min")
|
||||
if billed_minutes is None:
|
||||
billed_minutes = entry.get("fakturerbar_tid_min")
|
||||
@ -2880,16 +3075,48 @@ async def approve_time_entry_v1(
|
||||
billed_minutes = _round_up_minutes(faktisk, int(entry.get("round_block_min") or 30))
|
||||
billed_minutes = int(billed_minutes)
|
||||
|
||||
final_status = "approved" if billing_method == "invoice" else "billed"
|
||||
if billing_method == "prepaid":
|
||||
valid_card_ids = {int(card["id"]) for card in options.get("prepaid_cards", [])}
|
||||
if not prepaid_card_id or int(prepaid_card_id) not in valid_card_ids:
|
||||
raise HTTPException(status_code=409, detail="Vælg et aktivt klippekort for kunden")
|
||||
card = next(card for card in options["prepaid_cards"] if int(card["id"]) == int(prepaid_card_id))
|
||||
rounding = max(1, int(card.get("rounding_minutes") or entry.get("round_block_min") or 30))
|
||||
billed_minutes = _round_up_minutes(billed_minutes, rounding)
|
||||
hours = billed_minutes / 60.0
|
||||
debited = execute_query(
|
||||
"""
|
||||
UPDATE tticket_prepaid_cards
|
||||
SET used_hours = used_hours + %s
|
||||
WHERE id = %s AND status = 'active' AND remaining_hours >= %s
|
||||
RETURNING id, remaining_hours
|
||||
""",
|
||||
(hours, prepaid_card_id, hours),
|
||||
)
|
||||
if not debited:
|
||||
raise HTTPException(status_code=409, detail="Klippekortet har ikke timer nok")
|
||||
elif billing_method == "subscription":
|
||||
valid_agreement_ids = {int(agreement["id"]) for agreement in options.get("agreements", [])}
|
||||
if not agreement_id or int(agreement_id) not in valid_agreement_ids:
|
||||
raise HTTPException(status_code=409, detail="Vælg en aktiv aftale for kunden")
|
||||
billable = False
|
||||
elif billing_method in {"internal", "non_billable"}:
|
||||
billable = False
|
||||
|
||||
approved_by = _resolve_current_user_id(current_user)
|
||||
updated = execute_query(
|
||||
"""
|
||||
UPDATE tmodule_times
|
||||
SET entry_type = %s,
|
||||
work_type = %s,
|
||||
entry_status = 'godkendt',
|
||||
status = 'approved',
|
||||
status = %s,
|
||||
billable = %s,
|
||||
billing_method = %s,
|
||||
prepaid_card_id = %s,
|
||||
fixed_price_agreement_id = %s,
|
||||
fakturerbar_tid_min = CASE WHEN %s THEN %s ELSE 0 END,
|
||||
approved_hours = CASE WHEN %s THEN (%s::numeric / 60.0) ELSE 0 END,
|
||||
approved_hours = CASE WHEN %s AND %s > 0 THEN (%s::numeric / 60.0) ELSE NULL END,
|
||||
approved_at = %s,
|
||||
approved_by = %s,
|
||||
aktiv_timer = FALSE
|
||||
@ -2898,11 +3125,17 @@ async def approve_time_entry_v1(
|
||||
""",
|
||||
(
|
||||
entry_type,
|
||||
work_type,
|
||||
final_status,
|
||||
billable,
|
||||
billing_method,
|
||||
int(prepaid_card_id) if billing_method == "prepaid" else None,
|
||||
int(agreement_id) if billing_method == "subscription" else None,
|
||||
billable,
|
||||
billed_minutes,
|
||||
billable,
|
||||
billed_minutes,
|
||||
billed_minutes,
|
||||
datetime.now(),
|
||||
approved_by,
|
||||
time_id,
|
||||
|
||||
@ -114,7 +114,7 @@
|
||||
<div class="container-fluid py-4">
|
||||
<div class="log-hero">
|
||||
<h1 class="h4 mb-1">Medarbejder Log</h1>
|
||||
<div>Visualiser registreret tid over dag, uge og maaned, og find manglende tider/sager.</div>
|
||||
<div>Visualiser registreret tid over dag, uge og måned, og find manglende tider/sager.</div>
|
||||
</div>
|
||||
|
||||
<div class="log-card">
|
||||
@ -124,7 +124,7 @@
|
||||
<select id="granularity" class="form-select">
|
||||
<option value="day">Dag</option>
|
||||
<option value="week" selected>Uge</option>
|
||||
<option value="month">Maaned</option>
|
||||
<option value="month">Måned</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -136,7 +136,7 @@
|
||||
<input id="endDate" type="date" class="form-control">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small">Maal timer/dag</label>
|
||||
<label class="form-label small">Mål: timer/dag</label>
|
||||
<input id="targetHours" type="number" min="0" max="24" step="0.5" value="7.5" class="form-control">
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -57,6 +57,25 @@
|
||||
.status-rejected { background: #f8d7da; color: #842029; }
|
||||
.status-billed { background: #cfe2ff; color: #084298; }
|
||||
|
||||
.report-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.report-summary-card {
|
||||
padding: 0.9rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.report-summary-card .value { font-size: 1.35rem; font-weight: 700; }
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.report-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
{% endblock %}
|
||||
@ -69,9 +88,10 @@
|
||||
<h1 class="mb-1">Tidsregistreringer</h1>
|
||||
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary" onclick="loadData()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Opdater
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-success" onclick="exportReportCsv()"><i class="bi bi-file-earmark-spreadsheet"></i> Eksportér CSV</button>
|
||||
<button class="btn btn-outline-primary" onclick="loadData()"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@ -98,9 +118,32 @@
|
||||
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label>
|
||||
<input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted text-uppercase fw-bold">Arbejdstype</label>
|
||||
<select id="filter-work-type" class="form-select" onchange="loadData()">
|
||||
<option value="">Alle typer</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="troubleshooting">Fejlsøgning</option>
|
||||
<option value="development">Udvikling</option>
|
||||
<option value="maintenance">Vedligehold</option>
|
||||
<option value="on_site">Kørsel / On-site</option>
|
||||
<option value="meeting">Møde</option>
|
||||
<option value="other">Andet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted text-uppercase fw-bold">Fra dato</label>
|
||||
<input type="date" id="filter-start-date" class="form-control" onchange="loadData()">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted text-uppercase fw-bold">Til dato</label>
|
||||
<input type="date" id="filter-end-date" class="form-control" onchange="loadData()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="report-summary" class="report-summary mb-4"></div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="registrations-table">
|
||||
<div class="table-responsive">
|
||||
@ -111,6 +154,7 @@
|
||||
<th style="width: 20%;">Kunde</th>
|
||||
<th style="width: 25%;">Beskrivelse / Case</th>
|
||||
<th>Tekniker</th>
|
||||
<th>Type</th>
|
||||
<th class="text-center">Timer</th>
|
||||
<th class="text-center">Fakt.</th>
|
||||
<th>Status</th>
|
||||
@ -118,7 +162,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="8" class="text-center py-5 text-muted">Henter data...</td></tr>
|
||||
<tr><td colspan="9" class="text-center py-5 text-muted">Henter data...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -158,28 +202,36 @@
|
||||
debounceTimer = setTimeout(loadData, 500);
|
||||
}
|
||||
|
||||
const workTypeLabels = {
|
||||
support: 'Support', troubleshooting: 'Fejlsøgning', development: 'Udvikling',
|
||||
maintenance: 'Vedligehold', on_site: 'Kørsel / On-site', meeting: 'Møde', other: 'Andet'
|
||||
};
|
||||
|
||||
function buildReportParams(limit = 100) {
|
||||
const params = new URLSearchParams({ limit, offset: 0 });
|
||||
const values = {
|
||||
search: document.getElementById('filter-search').value,
|
||||
status: document.getElementById('filter-status').value,
|
||||
user_name: document.getElementById('filter-user').value,
|
||||
work_type: document.getElementById('filter-work-type').value,
|
||||
start_date: document.getElementById('filter-start-date').value,
|
||||
end_date: document.getElementById('filter-end-date').value
|
||||
};
|
||||
Object.entries(values).forEach(([key, value]) => { if (value) params.append(key, value); });
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const tbody = document.getElementById('table-body');
|
||||
const search = document.getElementById('filter-search').value;
|
||||
const status = document.getElementById('filter-status').value;
|
||||
const user = document.getElementById('filter-user').value;
|
||||
|
||||
// Build URL
|
||||
const params = new URLSearchParams({
|
||||
limit: 100, // Hardcoded limit for now
|
||||
offset: 0
|
||||
});
|
||||
|
||||
if (search) params.append('search', search);
|
||||
if (status) params.append('status', status);
|
||||
if (user) params.append('user_name', user);
|
||||
const params = buildReportParams();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
renderReportSummary(data);
|
||||
|
||||
if (data.times.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5">Ingen resultater fundet</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5">Ingen resultater fundet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -203,6 +255,7 @@
|
||||
<td>
|
||||
${t.user_name || '-'}
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">${workTypeLabels[t.work_type || 'support'] || t.work_type || 'Support'}</span></td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
|
||||
</td>
|
||||
@ -225,10 +278,46 @@
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderReportSummary(data) {
|
||||
const summary = data.summary || {};
|
||||
const cards = [
|
||||
['Timer', Number(summary.total_hours || 0).toFixed(2)],
|
||||
['Fakturerbare timer', Number(summary.billable_hours || 0).toFixed(2)],
|
||||
['Registreringer', summary.total_entries || 0],
|
||||
['Medarbejdere', summary.total_employees || 0],
|
||||
['Sager', summary.total_cases || 0]
|
||||
];
|
||||
const typeText = (data.by_work_type || [])
|
||||
.map(row => `${workTypeLabels[row.work_type] || row.work_type}: ${Number(row.hours || 0).toFixed(2)} t`)
|
||||
.join(' · ');
|
||||
document.getElementById('report-summary').innerHTML = cards.map(([label, value]) => `
|
||||
<div class="report-summary-card"><div class="small text-muted">${label}</div><div class="value">${value}</div></div>
|
||||
`).join('') + (typeText ? `<div class="small text-muted grid-column-span-all" style="grid-column:1/-1">Fordeling: ${typeText}</div>` : '');
|
||||
}
|
||||
|
||||
async function exportReportCsv() {
|
||||
const response = await fetch(`/api/v1/timetracking/times?${buildReportParams(10000).toString()}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) return alert(data.detail || 'Kunne ikke eksportere rapporten');
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
const rows = [['Dato', 'Kunde', 'Sag', 'Medarbejder', 'Type', 'Beskrivelse', 'Timer', 'Fakturerbar', 'Status']];
|
||||
(data.times || []).forEach(t => rows.push([
|
||||
t.worked_date, t.customer_name, t.case_title, t.user_name,
|
||||
workTypeLabels[t.work_type || 'support'] || t.work_type,
|
||||
t.description, t.original_hours, t.billable ? 'Ja' : 'Nej', t.status
|
||||
]));
|
||||
const csv = '\ufeff' + rows.map(row => row.map(quote).join(';')).join('\n');
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
link.download = `tidsrapport-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString('da-DK');
|
||||
|
||||
@ -126,7 +126,7 @@
|
||||
<div class="container-fluid py-4">
|
||||
<div class="report-hero">
|
||||
<h1>Servicekontrakt Rapport</h1>
|
||||
<p>Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.</p>
|
||||
<p>Vælg kunde og servicekontrakt for at se relaterede sager og tidsregistreringer fra vTiger.</p>
|
||||
</div>
|
||||
|
||||
<div class="report-card mb-3">
|
||||
@ -134,13 +134,13 @@
|
||||
<div>
|
||||
<label for="customerSelect" class="form-label fw-semibold">Kunde</label>
|
||||
<select id="customerSelect" class="form-select">
|
||||
<option value="">-- Vaelg kunde --</option>
|
||||
<option value="">-- Vælg kunde --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="contractSelect" class="form-label fw-semibold">Servicekontrakt</label>
|
||||
<select id="contractSelect" class="form-select" disabled>
|
||||
<option value="">-- Vaelg servicekontrakt --</option>
|
||||
<option value="">-- Vælg servicekontrakt --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@ -155,7 +155,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="filterHint" class="small text-muted mt-2">Start med at vaelge en kunde.</div>
|
||||
<div id="filterHint" class="small text-muted mt-2">Start med at vælge en kunde.</div>
|
||||
</div>
|
||||
|
||||
<div id="errorBox" class="alert alert-danger d-none" role="alert"></div>
|
||||
@ -221,7 +221,7 @@
|
||||
customerSelect.appendChild(option);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message || 'Fejl ved indlaesning af kunder');
|
||||
showError(error.message || 'Fejl ved indlæsning af kunder');
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,14 +229,14 @@
|
||||
state.selectedAccountId = event.target.value || '';
|
||||
state.selectedContractId = '';
|
||||
reportSection.classList.add('d-none');
|
||||
contractSelect.innerHTML = '<option value="">-- Vaelg servicekontrakt --</option>';
|
||||
contractSelect.innerHTML = '<option value="">-- Vælg servicekontrakt --</option>';
|
||||
contractSelect.disabled = !state.selectedAccountId;
|
||||
loadBtn.disabled = true;
|
||||
pdfBtn.disabled = true;
|
||||
excelBtn.disabled = true;
|
||||
|
||||
if (!state.selectedAccountId) {
|
||||
filterHint.textContent = 'Start med at vaelge en kunde.';
|
||||
filterHint.textContent = 'Start med at vælge en kunde.';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -260,7 +260,7 @@
|
||||
}
|
||||
|
||||
filterHint.textContent = state.contracts.length
|
||||
? 'Vaelg servicekontrakt og hent rapporten.'
|
||||
? 'Vælg servicekontrakt, og hent rapporten.'
|
||||
: 'Ingen servicekontrakter fundet for kunden.';
|
||||
} catch (error) {
|
||||
showError(error.message || 'Fejl ved hentning af servicekontrakter');
|
||||
@ -312,7 +312,7 @@
|
||||
document.getElementById('reportContractMeta').textContent = `${customer.account_name} (${customer.account_id})`;
|
||||
const contractLinkEl = document.getElementById('reportContractLink');
|
||||
if (contract.vtiger_url) {
|
||||
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn servicekontrakt i vTiger</a>`;
|
||||
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Åbn servicekontrakt i vTiger</a>`;
|
||||
} else {
|
||||
contractLinkEl.textContent = '';
|
||||
}
|
||||
@ -354,7 +354,7 @@
|
||||
<div class="case-title">${escapeHtml(caseItem.title || '-')}</div>
|
||||
<div class="small text-muted">CC-nummer: ${escapeHtml(caseItem.cc_number || caseItem.id || '-')}</div>
|
||||
<div class="small text-muted">Kontaktperson: ${escapeHtml(caseItem.contact_person || '-')}</div>
|
||||
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn case i vTiger</a>` : ''}</div>
|
||||
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Åbn sag i vTiger</a>` : ''}</div>
|
||||
<div class="small mt-1">${escapeHtml(caseItem.description || 'Ingen beskrivelse')}</div>
|
||||
</div>
|
||||
<div class="small text-end">
|
||||
|
||||
2
app/vendors/frontend/vendor_detail.html
vendored
2
app/vendors/frontend/vendor_detail.html
vendored
@ -210,7 +210,7 @@
|
||||
<div class="tab-pane fade" id="fakturaer">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="mb-0 fw-bold">Leverandør Fakturaer</h5>
|
||||
<h5 class="mb-0 fw-bold">Leverandørfakturaer</h5>
|
||||
<span class="badge bg-primary" id="invoiceCount">0</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
||||
14
migrations/1016_contact_merge_history.sql
Normal file
14
migrations/1016_contact_merge_history.sql
Normal 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.';
|
||||
@ -2,6 +2,7 @@
|
||||
let latestSections = {};
|
||||
let latestContextActions = { global: [], context: [] };
|
||||
let activeKey = 'timer';
|
||||
let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' };
|
||||
let overviewFilter = null;
|
||||
let ws = null;
|
||||
let pollTimer = null;
|
||||
@ -17,7 +18,7 @@
|
||||
let switchCaseState = {
|
||||
activeTimer: null,
|
||||
decision: 'unchanged',
|
||||
timers: { active: [], paused: [] },
|
||||
timers: { active: [], paused: [], stopped: [] },
|
||||
recentCases: [],
|
||||
unassignedCases: []
|
||||
};
|
||||
@ -855,10 +856,10 @@
|
||||
|
||||
if (key === 'overview') {
|
||||
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '<div><strong class="text-danger"><i class="bi bi-exclamation-octagon"></i> Hastesag:</strong> ' + esc(u.title) + ' <br><button class="btn btn-sm btn-outline-danger mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Vis sag</button></div>') : ['Ingen hastesager.'];
|
||||
if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>') : ['Alle systemer oppe.'];
|
||||
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>') : ['Ingen ESET incidents.'];
|
||||
if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <a class="btn btn-sm btn-outline-primary" href="/drift">Håndter i Drift</a></div>') : ['Alle systemer oppe.'];
|
||||
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <a class="btn btn-sm btn-outline-primary" href="/hardware/eset">Håndter</a></div>') : ['Ingen ESET incidents.'];
|
||||
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '<div><i class="bi bi-folder2-open text-primary"></i> ' + esc(c.title) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(c.id || 0) + '">Vis sag</button></div>') : ['Ingen åbne sager.'];
|
||||
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <button class="btn btn-sm btn-outline-primary mt-2">Åbn indbakke</button></div>'];
|
||||
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <a class="btn btn-sm btn-outline-primary mt-2" href="/emails">Åbn indbakke</a></div>'];
|
||||
if (overviewFilter === 'unassigned') return unassigned.list ? unassigned.list.map(u => '<div><i class="bi bi-person-x text-warning"></i> ' + esc(u.title || ('Sag #' + (u.id || ''))) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Åbn sag</button></div>') : ['Ingen åbne sager uden ansvarlig.'];
|
||||
|
||||
let out = [];
|
||||
@ -872,6 +873,15 @@
|
||||
out.push('<div>🎉 Alt ser grønt ud! Intet kritisk lige nu.</div>');
|
||||
}
|
||||
|
||||
const contextActions = (latestContextActions.context || []);
|
||||
const globalActions = (latestContextActions.global || []);
|
||||
const smartActions = contextActions.length ? contextActions : globalActions;
|
||||
if (smartActions.length) {
|
||||
out.push('<div class="mt-3 pt-3 border-top"><div class="small fw-semibold text-muted mb-2">Smarte handlinger</div><div class="d-flex flex-wrap gap-2">' + smartActions.map(function (action) {
|
||||
return '<button type="button" class="btn btn-sm btn-outline-primary" data-bb-create="' + esc(action.id) + '"><i class="bi ' + esc(action.icon || 'bi-lightning-charge') + ' me-1"></i>' + esc(action.label) + '</button>';
|
||||
}).join('') + '</div></div>');
|
||||
}
|
||||
|
||||
// Add quick note button on overview
|
||||
out.push('<div class="mt-3 pt-3 border-top"><div class="input-group"><input type="text" id="bbQuickNoteInput" class="form-control form-control-sm" placeholder="Skriv en quick note..." value="' + quickNoteValue + '"><button class="btn btn-outline-secondary btn-sm" id="bbQuickNoteSaveBtn"><i class="bi bi-pencil"></i> Gem note</button></div><div id="bbQuickNoteHint" class="small text-muted mt-2">' + esc(quickNoteHintState.message || 'Tip: gemmer som kommentar på aktiv/åben sag.') + '</div></div>');
|
||||
|
||||
@ -882,10 +892,10 @@
|
||||
if (timer.active_count > 0) {
|
||||
return (timer.list || []).map(t => {
|
||||
const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's');
|
||||
return '<div class="d-flex justify-content-between align-items-center"><span><i class="bi bi-stopwatch text-success"></i> ' + esc(t.desc) + ' (' + esc(elapsedText) + ')</span> <button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill"></i> Stop</button></div>';
|
||||
return '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
|
||||
});
|
||||
}
|
||||
return ['Ingen aktive timere lige nu.'];
|
||||
return ['<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen aktiv timer</strong><div class="small mt-1">Brug “Skift sag” i bundlinjen for at starte arbejdet.</div></div></div>'];
|
||||
}
|
||||
|
||||
if (key === 'messages') {
|
||||
@ -929,9 +939,9 @@
|
||||
|
||||
if (key === 'tasks') {
|
||||
if (tasks.count > 0) {
|
||||
return (tasks.list || []).map(t => '<div><i class="bi bi-calendar-check text-success"></i> <strong>' + esc(t.title) + '</strong> <span class="badge bg-secondary ms-2">' + esc(t.deadline) + '</span></div>');
|
||||
return (tasks.list || []).map(t => '<div class="d-flex align-items-center gap-2 min-w-0"><span class="rounded-circle bg-success-subtle text-success p-2"><i class="bi bi-check2"></i></span><div class="min-w-0"><strong class="d-block text-truncate">' + esc(t.title) + '</strong><span class="small text-muted">Prioritet: ' + esc(t.deadline) + '</span></div></div><span class="badge rounded-pill text-bg-light border">Aktuel</span>');
|
||||
}
|
||||
return ['Ingen aktuelle opgaver.'];
|
||||
return ['<div class="bb-panel-empty"><div><i class="bi bi-check2-circle"></i><strong>Du er ajour</strong><div class="small mt-1">Der er ingen aktuelle opgaver eller påmindelser.</div></div></div>'];
|
||||
}
|
||||
|
||||
if (key === 'notes') {
|
||||
@ -1153,8 +1163,12 @@
|
||||
const timerChip = byId('bbActiveTimerChip');
|
||||
const timerText = byId('bbActiveTimerText');
|
||||
const notifCount = byId('bbNotificationsCount');
|
||||
const pauseBtn = byId('bbTimerPauseBtn');
|
||||
const stopBtn = byId('bbTimerStopBtn');
|
||||
|
||||
const timer = ((latestSections || {}).timer || {}).active || {};
|
||||
const ownTimers = ((latestSections || {}).timer || {}).own || {};
|
||||
const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0;
|
||||
const hasActiveTimer = !!timer.active;
|
||||
if (timerChip && timerText) {
|
||||
timerChip.classList.toggle('is-hidden', !hasActiveTimer);
|
||||
@ -1170,6 +1184,15 @@
|
||||
const computed = Number(latestNotificationCount || 0) + unreadMessages;
|
||||
notifCount.textContent = String(computed);
|
||||
}
|
||||
if (pauseBtn) {
|
||||
pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer;
|
||||
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag senest pausede timer' : 'Ingen timer at pause');
|
||||
pauseBtn.innerHTML = hasActiveTimer ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>';
|
||||
}
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = !hasActiveTimer;
|
||||
stopBtn.title = hasActiveTimer ? 'Stop timer' : 'Ingen aktiv timer';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTabPanel() {
|
||||
@ -1193,6 +1216,7 @@
|
||||
}
|
||||
|
||||
const titleText = titleContainer.querySelector('.bb-tab-title-text');
|
||||
const descriptionEl = byId('bbTabDescription');
|
||||
|
||||
const titleByKey = {
|
||||
overview: 'Overblik',
|
||||
@ -1212,12 +1236,24 @@
|
||||
boss: 'bi-person-workspace'
|
||||
};
|
||||
|
||||
const descriptionByKey = {
|
||||
overview: 'Det vigtigste samlet ét sted.',
|
||||
timer: 'Se aktiv tid, stop registreringen eller skift direkte til en anden sag.',
|
||||
messages: 'Interne samtaler samlet efter modtager med tydelig læst-status.',
|
||||
tasks: 'Prioritér næste handling ud fra deadlines og aktuelle påmindelser.',
|
||||
notes: 'Skriv hurtigt til venstre og genbrug dine noter fra arkivet til højre.',
|
||||
boss: 'Fordel supportkøen ud fra kapacitet, hast og ventetid.'
|
||||
};
|
||||
|
||||
const activeTitle = titleByKey[activeKey] || 'Info';
|
||||
if (titleText) {
|
||||
titleText.textContent = activeTitle;
|
||||
} else {
|
||||
titleContainer.textContent = activeTitle;
|
||||
}
|
||||
if (descriptionEl) {
|
||||
descriptionEl.textContent = descriptionByKey[activeKey] || '';
|
||||
}
|
||||
|
||||
const iconSpan = titleContainer.querySelector('.bi');
|
||||
if (iconSpan) {
|
||||
@ -1228,6 +1264,7 @@
|
||||
const lines = listFor(activeKey, latestSections);
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'bb-tab-list';
|
||||
ul.classList.add('bb-panel-' + activeKey);
|
||||
|
||||
lines.forEach(function (line) {
|
||||
const li = document.createElement('li');
|
||||
@ -1238,11 +1275,16 @@
|
||||
|
||||
innerContent.innerHTML = '';
|
||||
|
||||
if (activeKey === 'timer') {
|
||||
renderTimerWorkQueue(innerContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add specific headers/controls based on active tab
|
||||
if (activeKey === 'tasks') {
|
||||
const topBar = document.createElement('div');
|
||||
topBar.className = 'bb-task-actions mb-3';
|
||||
topBar.innerHTML = '<button class="btn btn-primary btn-sm w-100 fw-bold shadow-sm" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right"></i> Giv mig næste opgave</button>';
|
||||
topBar.innerHTML = '<button class="btn btn-primary btn-sm fw-bold" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right me-1"></i>Giv mig næste opgave</button>';
|
||||
innerContent.appendChild(topBar);
|
||||
}
|
||||
if (activeKey === 'messages') {
|
||||
@ -1393,6 +1435,105 @@
|
||||
|
||||
}
|
||||
|
||||
function timerStateLabel(state) {
|
||||
if (state === 'active') return ['Aktiv', 'success'];
|
||||
if (state === 'paused') return ['Pauset', 'warning'];
|
||||
return ['Klar til registrering', 'info'];
|
||||
}
|
||||
|
||||
function formatTimerSeconds(value) {
|
||||
const seconds = Math.max(0, Number(value || 0));
|
||||
return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), Math.floor(seconds % 60)]
|
||||
.map(function (part) { return String(part).padStart(2, '0'); }).join(':');
|
||||
}
|
||||
|
||||
function renderTimerWorkQueue(container) {
|
||||
const scope = timerPanelState.scope;
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'd-flex justify-content-between align-items-center gap-2 mb-3';
|
||||
toolbar.innerHTML = '<div class="btn-group btn-group-sm" role="group" aria-label="Timervisning">' +
|
||||
'<button class="btn ' + (scope === 'mine' ? 'btn-primary' : 'btn-outline-secondary') + '" data-bb-timer-scope="mine">Mine timere</button>' +
|
||||
'<button class="btn ' + (scope === 'all' ? 'btn-primary' : 'btn-outline-secondary') + '" data-bb-timer-scope="all">Alle medarbejdere</button></div>' +
|
||||
'<span class="small text-muted">' + timerPanelState.rows.length + ' vist</span>';
|
||||
container.appendChild(toolbar);
|
||||
|
||||
if (timerPanelState.loading || !timerPanelState.loaded) {
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'bb-panel-empty';
|
||||
loading.innerHTML = '<div><span class="spinner-border spinner-border-sm me-2"></span>Henter timere...</div>';
|
||||
container.appendChild(loading);
|
||||
if (!timerPanelState.loading) loadTimerWorkQueue(scope);
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerPanelState.error) {
|
||||
const errorBox = document.createElement('div');
|
||||
errorBox.className = 'alert alert-warning d-flex justify-content-between align-items-center gap-3';
|
||||
errorBox.innerHTML = '<span><i class="bi bi-exclamation-triangle me-2"></i>' + escapeHtml(timerPanelState.error) + '</span>' +
|
||||
'<button class="btn btn-sm btn-outline-dark" data-bb-timer-scope="' + scope + '">Prøv igen</button>';
|
||||
container.appendChild(errorBox);
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'bb-timer-work-grid';
|
||||
if (!timerPanelState.rows.length) {
|
||||
list.innerHTML = '<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen timere at vise</strong></div></div>';
|
||||
} else {
|
||||
list.innerHTML = timerPanelState.rows.map(function (row) {
|
||||
const state = String(row.timer_state || 'pending_conversion');
|
||||
const label = timerStateLabel(state);
|
||||
const timeId = Number(row.id || row.time_entry_id || 0);
|
||||
const sagId = Number(row.sag_id || 0);
|
||||
const isOwn = row.is_own_timer === true;
|
||||
let action = '';
|
||||
if (isOwn && state === 'paused') action = '<button class="btn btn-sm btn-primary" data-bb-resume-time="' + timeId + '"><i class="bi bi-play-fill me-1"></i>Genoptag</button>';
|
||||
if (isOwn && state === 'pending_conversion') action = '<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '"><i class="bi bi-arrow-repeat me-1"></i>Konverter</button>';
|
||||
if (isOwn && state === 'active') action = '<button class="btn btn-sm btn-danger" data-bb-stop-time="' + timeId + '"><i class="bi bi-stop-fill me-1"></i>Stop</button>';
|
||||
const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed);
|
||||
const elapsed = state === 'active' ? '<span class="font-monospace">' + escapeHtml(elapsedValue) + '</span> · ' : '';
|
||||
const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder');
|
||||
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
|
||||
}).join('');
|
||||
}
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
function ownTimerRows(payload) {
|
||||
const groups = normalizeSwitchableTimerPayload(payload);
|
||||
return groups.active.map(function (row) {
|
||||
return Object.assign({}, row, { timer_state: 'active', is_own_timer: true });
|
||||
}).concat(groups.paused.map(function (row) {
|
||||
return Object.assign({}, row, { timer_state: 'paused', is_own_timer: true });
|
||||
}), groups.stopped.map(function (row) {
|
||||
return Object.assign({}, row, { timer_state: 'pending_conversion', is_own_timer: true });
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTimerWorkQueue(scope) {
|
||||
timerPanelState.scope = scope || 'mine';
|
||||
timerPanelState.loading = true;
|
||||
timerPanelState.error = '';
|
||||
try {
|
||||
if (timerPanelState.scope === 'mine') {
|
||||
const ownTimers = await fetchSwitchableTimers();
|
||||
timerPanelState.rows = ownTimerRows(ownTimers);
|
||||
switchCaseState.timers.stopped = timerPanelState.rows.filter(function (row) { return row.timer_state === 'pending_conversion'; });
|
||||
} else {
|
||||
const response = await fetch('/api/v1/timetracking/time/team-status?scope=all', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Kunne ikke hente alle medarbejderes timere');
|
||||
const payload = await response.json();
|
||||
timerPanelState.rows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
}
|
||||
timerPanelState.loaded = true;
|
||||
} catch (err) {
|
||||
timerPanelState.error = err && err.message ? err.message : 'Kunne ikke hente timeroversigten';
|
||||
timerPanelState.loaded = true;
|
||||
} finally {
|
||||
timerPanelState.loading = false;
|
||||
if (activeKey === 'timer') renderTabPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function bindSideTabs() {
|
||||
const buttons = document.querySelectorAll('.bb-tab-btn');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
@ -1680,14 +1821,15 @@
|
||||
}
|
||||
|
||||
function normalizeSwitchableTimerPayload(payload) {
|
||||
const out = { active: [], paused: [] };
|
||||
const out = { active: [], paused: [], stopped: [] };
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.active) || Array.isArray(payload.paused)) {
|
||||
if (Array.isArray(payload.active) || Array.isArray(payload.paused) || Array.isArray(payload.stopped)) {
|
||||
out.active = Array.isArray(payload.active) ? payload.active : [];
|
||||
out.paused = Array.isArray(payload.paused) ? payload.paused : [];
|
||||
out.stopped = Array.isArray(payload.stopped) ? payload.stopped : [];
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -1762,6 +1904,32 @@
|
||||
return sagId > 0 ? ('Sag #' + sagId) : 'Ukendt sag';
|
||||
}
|
||||
|
||||
function timerDurationLabel(timer) {
|
||||
let seconds = Number((timer && (timer.live_elapsed_seconds || timer.elapsed_seconds || timer.elapsed)) || 0);
|
||||
if (!seconds && timer && timer.start_tid && timer.slut_tid) {
|
||||
const start = new Date(timer.start_tid).getTime();
|
||||
const end = new Date(timer.slut_tid).getTime();
|
||||
if (Number.isFinite(start) && Number.isFinite(end)) {
|
||||
seconds = Math.max(0, Math.floor((end - start) / 1000) - Number(timer.pause_total_seconds || 0));
|
||||
}
|
||||
}
|
||||
if (!seconds && timer && timer.faktisk_tid_min) seconds = Number(timer.faktisk_tid_min) * 60;
|
||||
return formatTimerSeconds(seconds);
|
||||
}
|
||||
|
||||
function timerCaseInfo(timer, includeDuration) {
|
||||
const sagId = Number((timer && timer.sag_id) || 0);
|
||||
const customer = String((timer && timer.customer_name) || 'Ingen kunde');
|
||||
const contact = String((timer && timer.contact_name) || 'Ingen kontakt');
|
||||
const status = String((timer && timer.case_status) || 'Ukendt status');
|
||||
return '<strong class="bb-switch-info-title">' + timerDisplayName(timer) + '</strong>' +
|
||||
'<span class="bb-switch-info-meta">#' + sagId + '</span>' +
|
||||
'<span class="bb-switch-info-meta"><i class="bi bi-building me-1"></i>' + escapeHtml(customer) + '</span>' +
|
||||
'<span class="bb-switch-info-meta"><i class="bi bi-person me-1"></i>' + escapeHtml(contact) + '</span>' +
|
||||
'<span class="badge rounded-pill text-bg-light border bb-switch-status">' + escapeHtml(status) + '</span>' +
|
||||
(includeDuration ? '<span class="bb-switch-info-meta"><i class="bi bi-stopwatch me-1"></i>' + timerDurationLabel(timer) + '</span>' : '');
|
||||
}
|
||||
|
||||
function renderSwitchCaseLists() {
|
||||
const timersEl = byId('bbSwitchTimersList');
|
||||
const recentEl = byId('bbSwitchRecentCasesList');
|
||||
@ -1772,43 +1940,54 @@
|
||||
|
||||
const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : [];
|
||||
const paused = Array.isArray(switchCaseState.timers.paused) ? switchCaseState.timers.paused : [];
|
||||
const stopped = Array.isArray(switchCaseState.timers.stopped) ? switchCaseState.timers.stopped : [];
|
||||
const recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : [];
|
||||
const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : [];
|
||||
const showUnassigned = unassignedCases.length > 0;
|
||||
const searchEl = byId('bbSwitchCaseSearch');
|
||||
const query = String((searchEl && searchEl.value) || '').trim().toLocaleLowerCase('da-DK');
|
||||
|
||||
if (actionsEl) {
|
||||
actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer);
|
||||
}
|
||||
|
||||
if (!active.length && !paused.length) {
|
||||
timersEl.innerHTML = '<div class="list-group-item text-muted">Ingen aktive eller pausede timere.</div>';
|
||||
if (!active.length && !paused.length && !stopped.length) {
|
||||
timersEl.innerHTML = '<div class="bb-switch-empty">Ingen tidligere timere at fortsætte.</div>';
|
||||
} else {
|
||||
let timerItems = '';
|
||||
active.forEach(function (t) {
|
||||
const timeId = Number((t && (t.id || t.time_entry_id)) || 0);
|
||||
timerItems +=
|
||||
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
|
||||
'<div><span class="badge text-bg-success me-2">Aktiv</span>' + timerDisplayName(t) + '</div>' +
|
||||
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</button>' +
|
||||
'</div>';
|
||||
if (timeId > 0) {
|
||||
timerItems +=
|
||||
'<div class="list-group-item small text-muted border-top-0 pt-0">Timer ID: ' + timeId + '</div>';
|
||||
}
|
||||
'<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-success">Aktiv</span>' + timerCaseInfo(t, true) + '</div>' +
|
||||
'<button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button></div>';
|
||||
});
|
||||
|
||||
paused.forEach(function (t) {
|
||||
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
|
||||
timerItems +=
|
||||
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
|
||||
'<div><span class="badge text-bg-warning me-2">Pauset</span>' + timerDisplayName(t) + '</div>' +
|
||||
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</button>' +
|
||||
'</div>';
|
||||
'<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-warning">Pauset</span>' + timerCaseInfo(t, true) + '</div>' +
|
||||
'<div class="bb-switch-case-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button>' +
|
||||
'<button class="btn btn-sm btn-primary" data-bb-resume-time="' + timeId + '"><i class="bi bi-play-fill me-1"></i>Genoptag</button></div></div>';
|
||||
});
|
||||
|
||||
stopped.forEach(function (t) {
|
||||
const sagId = Number((t && t.sag_id) || 0);
|
||||
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
|
||||
timerItems +=
|
||||
'<div class="bb-switch-timer bb-switch-timer-pending">' +
|
||||
'<div class="bb-switch-timer-info">' + timerCaseInfo(t, true) + '</div>' +
|
||||
'<div class="bb-switch-case-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' +
|
||||
'<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '">Registrer</button></div></div>';
|
||||
});
|
||||
|
||||
timersEl.innerHTML = timerItems;
|
||||
}
|
||||
|
||||
const sourceCases = showUnassigned ? unassignedCases : recentCases;
|
||||
const sourceCases = (showUnassigned ? unassignedCases : recentCases).filter(function (row) {
|
||||
if (!query) return true;
|
||||
const searchable = String((row && (row.sag_id || row.id)) || '') + ' ' + String((row && (row.titel || row.title)) || '');
|
||||
return searchable.toLocaleLowerCase('da-DK').includes(query);
|
||||
});
|
||||
const titleEl = byId('bbSwitchCaseModalLabel');
|
||||
if (titleEl) {
|
||||
titleEl.innerHTML = showUnassigned
|
||||
@ -1817,26 +1996,94 @@
|
||||
}
|
||||
|
||||
if (!sourceCases.length) {
|
||||
recentEl.innerHTML = '<div class="list-group-item text-muted">Ingen sager at vise.</div>';
|
||||
recentEl.innerHTML = '<div class="bb-switch-empty"><i class="bi bi-search me-1"></i>' + (query ? 'Ingen sager matcher din søgning.' : 'Ingen sager at vise.') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
recentEl.innerHTML = sourceCases.map(function (row) {
|
||||
const caseId = Number((row && (row.sag_id || row.id)) || 0);
|
||||
const title = escapeHtml((row && (row.titel || row.title)) || (caseId > 0 ? ('Sag #' + caseId) : 'Ukendt sag'));
|
||||
const prefix = showUnassigned ? '<span class="badge text-bg-warning me-2">Uden ansvarlig</span>' : '<span class="badge text-bg-light border me-2">Senest</span>';
|
||||
const meta = showUnassigned ? '<span class="text-warning">Uden ansvarlig</span>' : 'Senest anvendt';
|
||||
return (
|
||||
'<div class="list-group-item d-flex justify-content-between align-items-start gap-2">' +
|
||||
'<div class="me-2">' + prefix + title + '</div>' +
|
||||
'<div class="d-flex gap-1">' +
|
||||
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + caseId + '">Åbn</button>' +
|
||||
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '">Start timer</button>' +
|
||||
'<div class="bb-switch-case">' +
|
||||
'<div class="bb-switch-case-main"><div class="bb-switch-case-title">' + title + '</div><div class="bb-switch-case-meta">Sag #' + caseId + ' · ' + meta + '</div></div>' +
|
||||
'<div class="bb-switch-case-actions">' +
|
||||
'<button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + caseId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button>' +
|
||||
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '"><i class="bi bi-play-fill me-1"></i>Start</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openTimeConversion(timeId) {
|
||||
const timer = (switchCaseState.timers.stopped || []).concat(timerPanelState.rows || []).find(function (row) {
|
||||
return Number((row && (row.time_entry_id || row.id)) || 0) === Number(timeId);
|
||||
});
|
||||
if (!timer) return;
|
||||
|
||||
const panel = byId('bbConvertTimePanel');
|
||||
const select = byId('bbConvertBillingMethod');
|
||||
if (!panel || !select) return;
|
||||
panel.dataset.timeId = String(timeId);
|
||||
byId('bbConvertTimeName').textContent = String((timer.sag_navn || timer.title) || ('Sag #' + (timer.sag_id || '')));
|
||||
byId('bbConvertWorkType').value = timer.work_type || 'support';
|
||||
byId('bbConvertMinutes').value = Number(timer.fakturerbar_tid_min != null ? timer.fakturerbar_tid_min : (timer.faktisk_tid_min || 0));
|
||||
select.innerHTML = '<option>Henter muligheder...</option>';
|
||||
panel.classList.remove('d-none');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/timetracking/time/' + Number(timeId) + '/settlement-options', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Kunne ikke hente afregningsmuligheder');
|
||||
const options = await response.json();
|
||||
const recommended = options.recommended || { method: 'invoice' };
|
||||
let html = '<option value="invoice">Faktura</option>';
|
||||
(options.prepaid_cards || []).forEach(function (card) {
|
||||
html += '<option value="prepaid:' + Number(card.id) + '">Klippekort #' + escapeHtml(card.card_number || card.id) + ' (' + Number(card.remaining_hours || 0).toFixed(2) + ' t tilbage)</option>';
|
||||
});
|
||||
(options.agreements || []).forEach(function (agreement) {
|
||||
html += '<option value="subscription:' + Number(agreement.id) + '">Abonnement #' + escapeHtml(agreement.agreement_number || agreement.id) + '</option>';
|
||||
});
|
||||
html += '<option value="internal">Intern tid</option><option value="non_billable">Ikke fakturerbar</option>';
|
||||
select.innerHTML = html;
|
||||
const recommendedId = recommended.prepaid_card_id || recommended.fixed_price_agreement_id;
|
||||
select.value = recommended.method + (recommendedId ? ':' + recommendedId : '');
|
||||
byId('bbConvertRecommendation').textContent = 'Foreslået: ' + (recommended.reason || 'Faktura');
|
||||
} catch (err) {
|
||||
select.innerHTML = '<option value="invoice">Faktura</option><option value="internal">Intern tid</option>';
|
||||
byId('bbConvertRecommendation').textContent = err.message || 'Kunne ikke hente forslag.';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTimeConversion() {
|
||||
const panel = byId('bbConvertTimePanel');
|
||||
const button = byId('bbConvertSubmit');
|
||||
const timeId = Number((panel && panel.dataset.timeId) || 0);
|
||||
if (!timeId || !button) return;
|
||||
const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':');
|
||||
const payload = {
|
||||
billing_method: selected[0],
|
||||
work_type: byId('bbConvertWorkType').value,
|
||||
fakturerbar_tid_min: Math.max(0, Number(byId('bbConvertMinutes').value || 0)),
|
||||
entry_type: 'manuel'
|
||||
};
|
||||
if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]);
|
||||
if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]);
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/timetracking/time/' + timeId + '/approve', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
const result = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden');
|
||||
panel.classList.add('d-none');
|
||||
await loadSwitchCaseData();
|
||||
if (activeKey === 'timer') await loadTimerWorkQueue(timerPanelState.scope);
|
||||
} catch (err) {
|
||||
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke konvertere tiden.'));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSwitchCaseData(options) {
|
||||
const opts = options || {};
|
||||
switchCaseState.decision = 'unchanged';
|
||||
@ -1845,7 +2092,7 @@
|
||||
: null;
|
||||
switchCaseState.unassignedCases = [];
|
||||
switchCaseState.recentCases = [];
|
||||
switchCaseState.timers = { active: [], paused: [] };
|
||||
switchCaseState.timers = { active: [], paused: [], stopped: [] };
|
||||
|
||||
if (opts.onlyUnassigned) {
|
||||
const unassigned = (((latestSections || {}).unassigned || {}).list || []);
|
||||
@ -1937,7 +2184,7 @@
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
window.location.href = '/sag/' + validCaseId;
|
||||
window.location.href = '/sag/' + validCaseId + '/v3';
|
||||
} catch (err) {
|
||||
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke starte timer for sag.'));
|
||||
}
|
||||
@ -1952,11 +2199,41 @@
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
window.location.href = '/sag/' + validCaseId;
|
||||
window.location.href = '/sag/' + validCaseId + '/v3';
|
||||
}
|
||||
|
||||
function executeBottomBarAction(action) {
|
||||
if (!action) return false;
|
||||
const command = String(action.command || '');
|
||||
if (command.indexOf('case_add:') === 0) {
|
||||
const caseAction = command.slice('case_add:'.length);
|
||||
if (typeof window.openCaseModuleAddPanel === 'function' && typeof window.openCaseAddAction === 'function') {
|
||||
Promise.resolve(window.openCaseModuleAddPanel()).then(function () {
|
||||
return window.openCaseAddAction(caseAction);
|
||||
});
|
||||
setExpanded(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (command === 'switch_timer') {
|
||||
openSwitchCaseModal();
|
||||
return true;
|
||||
}
|
||||
if (command === 'open_notes') {
|
||||
activeKey = 'notes';
|
||||
setExpanded(true);
|
||||
renderTabPanel();
|
||||
return true;
|
||||
}
|
||||
if (action.action) {
|
||||
window.location.href = action.action;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveQuickNoteCaseId() {
|
||||
const match = (window.location.pathname || '').match(/^\/sag\/(\d+)$/);
|
||||
const match = (window.location.pathname || '').match(/^\/sag\/(\d+)(?:\/v3)?\/?$/);
|
||||
if (match && match[1]) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
@ -2319,13 +2596,6 @@
|
||||
|
||||
if (notificationsBtn) {
|
||||
notificationsBtn.addEventListener('click', function () {
|
||||
if (latestNotifications.length > 0) {
|
||||
const first = latestNotifications[0] || {};
|
||||
if (first.action) {
|
||||
window.location.href = first.action;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const trigger = byId('globalRemindersBtn');
|
||||
if (trigger) {
|
||||
trigger.click();
|
||||
@ -2378,7 +2648,9 @@
|
||||
|
||||
if (timerChip) {
|
||||
timerChip.addEventListener('click', function () {
|
||||
window.location.href = '/timetracking';
|
||||
const timer = (((latestSections || {}).timer || {}).active || {});
|
||||
const sagId = Number(timer.sag_id || 0);
|
||||
window.location.href = sagId > 0 ? ('/sag/' + sagId) : '/timetracking';
|
||||
});
|
||||
}
|
||||
|
||||
@ -2397,15 +2669,26 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (matched && matched.action) {
|
||||
window.location.href = matched.action;
|
||||
}
|
||||
executeBottomBarAction(matched);
|
||||
});
|
||||
}
|
||||
|
||||
function bindDynamicActions() {
|
||||
document.addEventListener('click', function (e) {
|
||||
const target = e.target;
|
||||
const timerScopeButton = target && target.closest('[data-bb-timer-scope]');
|
||||
if (timerScopeButton) {
|
||||
timerPanelState.loaded = false;
|
||||
loadTimerWorkQueue(timerScopeButton.getAttribute('data-bb-timer-scope') || 'mine');
|
||||
renderTabPanel();
|
||||
return;
|
||||
}
|
||||
const caseLink = target && target.closest('[data-bb-case-link]');
|
||||
if (caseLink && !target.closest('button, a, input, select, textarea')) {
|
||||
const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0);
|
||||
if (sagId > 0) openCaseDetail(sagId);
|
||||
return;
|
||||
}
|
||||
const btn = target && target.closest('button');
|
||||
if (!btn) return;
|
||||
|
||||
@ -2661,12 +2944,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (btn.hasAttribute('data-bb-cancel-convert')) {
|
||||
const panel = byId('bbConvertTimePanel');
|
||||
if (panel) panel.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn.id === 'bbConvertSubmit') {
|
||||
submitTimeConversion();
|
||||
return;
|
||||
}
|
||||
|
||||
const convertTimeId = Number(btn.getAttribute('data-bb-convert-time') || 0);
|
||||
if (convertTimeId > 0) {
|
||||
openTimeConversion(convertTimeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0);
|
||||
if (openCaseId > 0) {
|
||||
openCaseDetail(openCaseId);
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeTimeId = Number(btn.getAttribute('data-bb-resume-time') || 0);
|
||||
if (resumeTimeId > 0) {
|
||||
btn.disabled = true;
|
||||
switchCaseStatusMessage('<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Genoptager timer...');
|
||||
resumeTimer(resumeTimeId)
|
||||
.then(fetchBottomBarState)
|
||||
.then(function (state) {
|
||||
applyState(state);
|
||||
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timeren er genoptaget.');
|
||||
return loadSwitchCaseData();
|
||||
})
|
||||
.catch(function (err) {
|
||||
btn.disabled = false;
|
||||
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke genoptage timer.'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startCaseId = Number(btn.getAttribute('data-bb-start-case') || 0);
|
||||
if (startCaseId > 0) {
|
||||
startTimerForCase(startCaseId);
|
||||
@ -2878,7 +3196,7 @@
|
||||
}
|
||||
if (bossAction === 'open_case') {
|
||||
const caseId = Number(btn.getAttribute('data-case-id') || 0);
|
||||
window.location.href = caseId > 0 ? ('/sag/' + caseId) : '/sag';
|
||||
window.location.href = caseId > 0 ? ('/sag/' + caseId + '/v3') : '/sag';
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -2998,6 +3316,10 @@
|
||||
|
||||
document.addEventListener('input', function (e) {
|
||||
const target = e.target;
|
||||
if (target && target.id === 'bbSwitchCaseSearch') {
|
||||
renderSwitchCaseLists();
|
||||
return;
|
||||
}
|
||||
if (!target || target.id !== 'bbQuickNoteInput') {
|
||||
if (target && target.id === 'bbNoteTitleInput') {
|
||||
noteEditorState.title = String(target.value || '');
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-file-earmark-check me-2"></i>Vaelg opgave-template</h5>
|
||||
<h5 class="modal-title"><i class="bi bi-file-earmark-check me-2"></i>Vælg opgave-template</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@ -40,9 +40,9 @@
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label">Template-kilde</label>
|
||||
<select id="ttSource" class="form-select">
|
||||
<option value="all" selected>Firma + faelles</option>
|
||||
<option value="all" selected>Firma + fælles</option>
|
||||
<option value="company">Kun firma</option>
|
||||
<option value="global">Kun faelles</option>
|
||||
<option value="global">Kun fælles</option>
|
||||
<option value="internal">Kun intern</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -54,7 +54,7 @@
|
||||
<option value="offboarding">Offboarding</option>
|
||||
<option value="simkort">Mobil / SIM-kort</option>
|
||||
<option value="hardwarebestilling">Hardwarebestilling</option>
|
||||
<option value="brugerandring">Brugeraendring</option>
|
||||
<option value="brugerandring">Brugerændring</option>
|
||||
<option value="andet">Andet</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -65,7 +65,7 @@
|
||||
|
||||
<div class="col-lg-8">
|
||||
<label class="form-label">Template</label>
|
||||
<input id="ttTemplateSearch" class="form-control mb-2" placeholder="Soeg template..." />
|
||||
<input id="ttTemplateSearch" class="form-control mb-2" placeholder="Søg template..." />
|
||||
<select id="ttTemplate" class="form-select" size="8"></select>
|
||||
<div id="ttTemplateEmpty" class="small text-muted mt-2 d-none">Ingen templates matchede dit filter.</div>
|
||||
</div>
|
||||
@ -74,15 +74,15 @@
|
||||
<label class="form-label">Oprettelsestype</label>
|
||||
<select id="ttMode" class="form-select mb-3">
|
||||
<option value="subcases">Opret som undersager</option>
|
||||
<option value="tasks">Opret som tasks paa nuvaerende sag</option>
|
||||
<option value="tasks">Opret som opgaver på nuværende sag</option>
|
||||
<option value="combined" selected>Kombineret</option>
|
||||
</select>
|
||||
|
||||
<label class="form-label">Ansvarlig</label>
|
||||
<select id="ttAssigneeMode" class="form-select mb-2">
|
||||
<option value="template_default" selected>Brug standard fra template</option>
|
||||
<option value="specific_user">Vaelg specifik medarbejder</option>
|
||||
<option value="specific_role">Vaelg team/rolle</option>
|
||||
<option value="specific_user">Vælg specifik medarbejder</option>
|
||||
<option value="specific_role">Vælg team/rolle</option>
|
||||
</select>
|
||||
<input id="ttAssigneeUserId" type="number" class="form-control mb-2 d-none" placeholder="Medarbejder ID" min="1" step="1" />
|
||||
<input id="ttAssigneeRoleId" type="number" class="form-control d-none" placeholder="Rolle ID" min="1" step="1" />
|
||||
@ -100,10 +100,10 @@
|
||||
|
||||
<div class="border-top mt-4 pt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Seneste template-koersler paa sagen</h6>
|
||||
<h6 class="mb-0">Seneste template-kørsler på sagen</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="ttRefreshRunsBtn">Opdater</button>
|
||||
</div>
|
||||
<div id="ttRunHistory" class="small text-muted">Ingen template-koersler endnu.</div>
|
||||
<div id="ttRunHistory" class="small text-muted">Ingen template-kørsler endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@ -194,7 +194,7 @@
|
||||
});
|
||||
|
||||
select.innerHTML = filtered.map((template) => {
|
||||
const scopeLabel = template.template_type === 'company' ? 'Firma' : template.template_type === 'global' ? 'Faelles' : 'Intern';
|
||||
const scopeLabel = template.template_type === 'company' ? 'Firma' : template.template_type === 'global' ? 'Fælles' : 'Intern';
|
||||
const cat = template.category || 'andet';
|
||||
return `<option value="${template.id}">${template.name} [${scopeLabel}] (${cat})</option>`;
|
||||
}).join('');
|
||||
@ -225,7 +225,7 @@
|
||||
function buildPayload() {
|
||||
const templateId = Number(document.getElementById('ttTemplate').value);
|
||||
if (!templateId) {
|
||||
throw new Error('Vaelg en template');
|
||||
throw new Error('Vælg en template');
|
||||
}
|
||||
|
||||
const mode = document.getElementById('ttMode').value;
|
||||
@ -312,7 +312,7 @@
|
||||
async function runTemplate() {
|
||||
if (!currentCaseId) return;
|
||||
if (!currentPreview) {
|
||||
notify('Koer preview foerst', 'error');
|
||||
notify('Kør forhåndsvisning først', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -332,11 +332,11 @@
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.detail || 'Koersel fejlede');
|
||||
throw new Error(body.detail || 'Kørsel fejlede');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
notify(`Template koert. Oprettet ${result.summary?.tasks || 0} tasks og ${result.summary?.subcases || 0} undersager.`, 'success');
|
||||
notify(`Template kørt. Oprettet ${result.summary?.tasks || 0} opgaver og ${result.summary?.subcases || 0} undersager.`, 'success');
|
||||
|
||||
if (typeof window.syncCaseTagsUi === 'function') {
|
||||
window.syncCaseTagsUi();
|
||||
@ -347,10 +347,10 @@
|
||||
await loadRunHistory();
|
||||
|
||||
document.getElementById('ttRunBtn').disabled = true;
|
||||
document.getElementById('ttPreviewSummary').textContent = 'Template koert. Vaelg en ny template eller opdater preview igen.';
|
||||
document.getElementById('ttPreviewSummary').textContent = 'Template kørt. Vælg en ny template eller opdater forhåndsvisningen igen.';
|
||||
} catch (error) {
|
||||
console.error('Run failed:', error);
|
||||
notify(error.message || 'Koersel fejlede', 'error');
|
||||
notify(error.message || 'Kørsel fejlede', 'error');
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
runButton.textContent = originalText;
|
||||
@ -383,7 +383,7 @@
|
||||
const container = document.getElementById('ttRunHistory');
|
||||
if (!container || !currentCaseId) return;
|
||||
|
||||
container.innerHTML = '<div class="text-muted">Indlaeser historik...</div>';
|
||||
container.innerHTML = '<div class="text-muted">Indlæser historik...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/cases/${currentCaseId}/template-runs?limit=10`, {
|
||||
@ -397,7 +397,7 @@
|
||||
|
||||
const rows = await response.json();
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted">Ingen template-koersler endnu.</div>';
|
||||
container.innerHTML = '<div class="text-muted">Ingen template-kørsler endnu.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -421,7 +421,7 @@
|
||||
<div class="small mt-1">Oprettet: ${taskCount} opgaver, ${subcaseCount} undersager</div>
|
||||
${run.template_id ? `
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="window.reuseCaseTemplateFromHistory(${Number(run.template_id)})">Vaelg igen</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="window.reuseCaseTemplateFromHistory(${Number(run.template_id)})">Vælg igen</button>
|
||||
</div>
|
||||
` : ''}
|
||||
${run.error_message ? `<div class="small text-danger mt-1">${escapeHtml(run.error_message)}</div>` : ''}
|
||||
|
||||
44
tests/test_danish_ui_text.py
Normal file
44
tests/test_danish_ui_text.py
Normal 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}"
|
||||
15
tests/test_global_search.py
Normal file
15
tests/test_global_search.py
Normal 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
|
||||
@ -127,6 +127,49 @@ def test_relation_quick_task_uses_todo_steps_api():
|
||||
assert "due_date: due" in template
|
||||
|
||||
|
||||
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||
|
||||
assert "openForwardLinkedEmail('latest')" in template
|
||||
assert "openForwardLinkedEmail('thread')" in template
|
||||
assert "stripQuotedEmailHistory" in template
|
||||
assert "caseEmailComposeMode = 'forward'" in template
|
||||
assert "thread_email_id: isNewThread ? null" in template
|
||||
assert "linkedEmailsCache\n .filter" in template
|
||||
|
||||
|
||||
def test_case_email_snippet_removes_embedded_css_and_scripts():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||
|
||||
assert "function emailSnippetAsPlainText(email)" in template
|
||||
assert "style, script, noscript, template, head" in template
|
||||
assert "const snippet = emailSnippetAsPlainText(e).slice(0, 130);" in template
|
||||
assert "snippetSource.replace(/<[^>]+>/g" not in template
|
||||
|
||||
|
||||
def test_comment_composer_can_close_case_or_wait_for_customer():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||
|
||||
assert 'id="commentActionCloseCase"' in template
|
||||
assert 'id="commentActionAwaitCustomer"' in template
|
||||
assert "actions.push('close_case')" in template
|
||||
assert "actions.push('await_customer')" in template
|
||||
assert "await changeCaseStatusFromComment('lukket')" in template
|
||||
assert "resolveCommentAwaitCustomerStatus()" in template
|
||||
assert "awaitCustomerCheckbox.checked = false" in template
|
||||
assert "closeCaseCheckbox.checked = false" in template
|
||||
|
||||
|
||||
def test_comment_actions_use_styled_accessible_chips():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||
|
||||
assert 'class="comment-action-chips"' in template
|
||||
assert template.count('class="comment-action-chip action-') == 5
|
||||
assert '.comment-action-chip:has(input:checked)' in template
|
||||
assert 'for="commentActionCloseCase"' in template
|
||||
assert 'for="commentActionAwaitCustomer"' in template
|
||||
|
||||
|
||||
def test_safe_case_html_renders_formatting_and_drops_executable_code():
|
||||
result = sanitize_safe_html(
|
||||
'<style>body{display:none}</style>'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user