diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py
index ebede4c..109cb63 100644
--- a/app/auth/backend/router.py
+++ b/app/auth/backend/router.py
@@ -74,6 +74,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
requires_2fa_setup = (
not user.get("is_shadow_admin", False)
+ and not settings.AUTH_DISABLE_2FA
+ and AuthService.is_2fa_supported()
and not user.get("is_2fa_enabled", False)
)
@@ -139,10 +141,18 @@ async def setup_2fa(current_user: dict = Depends(get_current_user)):
detail="Shadow admin cannot configure 2FA",
)
- result = AuthService.setup_user_2fa(
- user_id=current_user["id"],
- username=current_user["username"]
- )
+ try:
+ result = AuthService.setup_user_2fa(
+ user_id=current_user["id"],
+ username=current_user["username"]
+ )
+ except RuntimeError as exc:
+ if "2FA columns missing" in str(exc):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="2FA er ikke tilgaengelig i denne database (mangler kolonner).",
+ )
+ raise
return result
diff --git a/app/backups/backend/service.py b/app/backups/backend/service.py
index 1c3b184..17a7b06 100644
--- a/app/backups/backend/service.py
+++ b/app/backups/backend/service.py
@@ -25,8 +25,26 @@ class BackupService:
"""Service for managing backup operations"""
def __init__(self):
- self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
- self.backup_dir.mkdir(parents=True, exist_ok=True)
+ configured_backup_dir = Path(settings.BACKUP_STORAGE_PATH)
+ self.backup_dir = configured_backup_dir
+ try:
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ # Local development can run outside Docker where /app is not writable.
+ # Fall back to the workspace data path so app startup does not fail.
+ if str(configured_backup_dir).startswith('/app/'):
+ project_root = Path(__file__).resolve().parents[3]
+ fallback_dir = project_root / 'data' / 'backups'
+ logger.warning(
+ "⚠️ Backup path %s not writable (%s). Using fallback %s",
+ configured_backup_dir,
+ exc,
+ fallback_dir,
+ )
+ fallback_dir.mkdir(parents=True, exist_ok=True)
+ self.backup_dir = fallback_dir
+ else:
+ raise
# Subdirectories for different backup types
self.db_dir = self.backup_dir / "database"
diff --git a/app/core/auth_dependencies.py b/app/core/auth_dependencies.py
index 15d3b8e..0d84d31 100644
--- a/app/core/auth_dependencies.py
+++ b/app/core/auth_dependencies.py
@@ -15,6 +15,21 @@ logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
+def _users_column_exists(column_name: str) -> bool:
+ result = execute_query_single(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'users'
+ AND column_name = %s
+ LIMIT 1
+ """,
+ (column_name,),
+ )
+ return bool(result)
+
+
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
@@ -70,9 +85,11 @@ async def get_current_user(
}
# Get additional user details from database
+ is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
user_details = execute_query_single(
- "SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
- (user_id,))
+ f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
+ (user_id,),
+ )
return {
"id": user_id,
diff --git a/app/core/auth_service.py b/app/core/auth_service.py
index 99567ae..685833d 100644
--- a/app/core/auth_service.py
+++ b/app/core/auth_service.py
@@ -15,6 +15,28 @@ import logging
logger = logging.getLogger(__name__)
+_users_column_cache: Dict[str, bool] = {}
+
+
+def _users_column_exists(column_name: str) -> bool:
+ if column_name in _users_column_cache:
+ return _users_column_cache[column_name]
+
+ result = execute_query_single(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'users'
+ AND column_name = %s
+ LIMIT 1
+ """,
+ (column_name,),
+ )
+ exists = bool(result)
+ _users_column_cache[column_name] = exists
+ return exists
+
# JWT Settings
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256"
@@ -25,6 +47,11 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"],
class AuthService:
"""Service for authentication and authorization"""
+
+ @staticmethod
+ def is_2fa_supported() -> bool:
+ """Return True only when required 2FA columns exist in users table."""
+ return _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret")
@staticmethod
def hash_password(password: str) -> str:
@@ -89,6 +116,9 @@ class AuthService:
@staticmethod
def setup_user_2fa(user_id: int, username: str) -> Dict:
"""Create and store a new TOTP secret (not enabled until verified)"""
+ if not AuthService.is_2fa_supported():
+ raise RuntimeError("2FA columns missing in users table")
+
secret = AuthService.generate_2fa_secret()
execute_update(
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
@@ -103,6 +133,9 @@ class AuthService:
@staticmethod
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Enable 2FA after verifying TOTP code"""
+ if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
+ return False
+
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
@@ -123,6 +156,9 @@ class AuthService:
@staticmethod
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Disable 2FA after verifying TOTP code"""
+ if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
+ return False
+
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
@@ -151,10 +187,11 @@ class AuthService:
if not user:
return False
- execute_update(
- "UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
- (user_id,)
- )
+ if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"):
+ execute_update(
+ "UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
+ (user_id,)
+ )
return True
@staticmethod
@@ -256,13 +293,18 @@ class AuthService:
request_username = (username or "").strip().lower()
# Get user
+ is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
+ totp_expr = "totp_secret" if _users_column_exists("totp_secret") else "NULL::text AS totp_secret"
+ last_2fa_expr = "last_2fa_at" if _users_column_exists("last_2fa_at") else "NULL::timestamp AS last_2fa_at"
+
user = execute_query_single(
- """SELECT user_id, username, email, password_hash, full_name,
+ f"""SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until,
- is_2fa_enabled, totp_secret, last_2fa_at
+ {is_2fa_expr}, {totp_expr}, {last_2fa_expr}
FROM users
WHERE username = %s OR email = %s""",
- (username, username))
+ (username, username),
+ )
if not user:
# Shadow Admin fallback (only when no regular user matches)
@@ -367,10 +409,11 @@ class AuthService:
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
- execute_update(
- "UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
- (user['user_id'],)
- )
+ if _users_column_exists("last_2fa_at"):
+ execute_update(
+ "UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
+ (user['user_id'],)
+ )
# Success! Reset failed attempts and update last login
execute_update(
@@ -416,6 +459,9 @@ class AuthService:
@staticmethod
def is_user_2fa_enabled(user_id: int) -> bool:
"""Check if user has 2FA enabled"""
+ if not _users_column_exists("is_2fa_enabled"):
+ return False
+
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
diff --git a/app/core/database.py b/app/core/database.py
index 03f715e..317e41f 100644
--- a/app/core/database.py
+++ b/app/core/database.py
@@ -6,6 +6,7 @@ PostgreSQL connection and helpers using psycopg2
import psycopg2
from psycopg2.extras import RealDictCursor
from psycopg2.pool import SimpleConnectionPool
+from functools import lru_cache
from typing import Optional
import logging
@@ -128,3 +129,34 @@ def execute_query_single(query: str, params: tuple = None):
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
result = execute_query(query, params)
return result[0] if result and len(result) > 0 else None
+
+
+@lru_cache(maxsize=256)
+def table_has_column(table_name: str, column_name: str, schema: str = "public") -> bool:
+ """Return whether a column exists in the current database schema."""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = %s
+ AND table_name = %s
+ AND column_name = %s
+ LIMIT 1
+ """,
+ (schema, table_name, column_name),
+ )
+ return cursor.fetchone() is not None
+ except Exception as e:
+ logger.warning(
+ "Schema lookup failed for %s.%s.%s: %s",
+ schema,
+ table_name,
+ column_name,
+ e,
+ )
+ return False
+ finally:
+ release_db_connection(conn)
diff --git a/app/dashboard/backend/views.py b/app/dashboard/backend/views.py
index f1fa441..48cf509 100644
--- a/app/dashboard/backend/views.py
+++ b/app/dashboard/backend/views.py
@@ -125,10 +125,24 @@ async def dashboard(request: Request):
from app.core.database import execute_query
- result = execute_query_single(unknown_query)
- unknown_count = result['count'] if result else 0
-
- raw_alerts = execute_query(bankruptcy_query) or []
+ try:
+ result = execute_query_single(unknown_query)
+ unknown_count = result['count'] if result else 0
+ except Exception as exc:
+ if "tticket_worklog" in str(exc):
+ logger.warning("⚠️ tticket_worklog table not found; defaulting unknown worklog count to 0")
+ unknown_count = 0
+ else:
+ raise
+
+ try:
+ raw_alerts = execute_query(bankruptcy_query) or []
+ except Exception as exc:
+ if "email_messages" in str(exc):
+ logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
+ raw_alerts = []
+ else:
+ raise
bankruptcy_alerts = []
for alert in raw_alerts:
diff --git a/app/models/schemas.py b/app/models/schemas.py
index 4c28053..e0f5093 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -280,6 +280,7 @@ class TodoStepCreate(TodoStepBase):
class TodoStepUpdate(BaseModel):
"""Schema for updating a todo step"""
is_done: Optional[bool] = None
+ is_next: Optional[bool] = None
class TodoStep(TodoStepBase):
@@ -287,6 +288,7 @@ class TodoStep(TodoStepBase):
id: int
sag_id: int
is_done: bool
+ is_next: bool = False
created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None
created_at: datetime
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 3f47f80..ae925cf 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -411,7 +411,7 @@ async def list_todo_steps(sag_id: int):
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
WHERE t.sag_id = %s AND t.deleted_at IS NULL
- ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
+ ORDER BY t.is_done ASC, t.is_next DESC, t.due_date NULLS LAST, t.created_at DESC
"""
return execute_query(query, (sag_id,)) or []
except Exception as e:
@@ -466,34 +466,63 @@ async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
try:
- if data.is_done is None:
- raise HTTPException(status_code=400, detail="is_done is required")
+ if data.is_done is None and data.is_next is None:
+ raise HTTPException(status_code=400, detail="Provide is_done or is_next")
- user_id = _get_user_id_from_request(request)
- if data.is_done:
- update_query = """
- UPDATE sag_todo_steps
- SET is_done = TRUE,
- completed_by_user_id = %s,
- completed_at = CURRENT_TIMESTAMP
- WHERE id = %s AND deleted_at IS NULL
- RETURNING id
- """
- result = execute_query(update_query, (user_id, step_id))
- else:
- update_query = """
- UPDATE sag_todo_steps
- SET is_done = FALSE,
- completed_by_user_id = NULL,
- completed_at = NULL
- WHERE id = %s AND deleted_at IS NULL
- RETURNING id
- """
- result = execute_query(update_query, (step_id,))
-
- if not result:
+ step_row = execute_query_single(
+ "SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL",
+ (step_id,)
+ )
+ if not step_row:
raise HTTPException(status_code=404, detail="Todo step not found")
+ if data.is_done is not None:
+ user_id = _get_user_id_from_request(request)
+ if data.is_done:
+ update_query = """
+ UPDATE sag_todo_steps
+ SET is_done = TRUE,
+ is_next = FALSE,
+ completed_by_user_id = %s,
+ completed_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND deleted_at IS NULL
+ RETURNING id
+ """
+ execute_query(update_query, (user_id, step_id))
+ else:
+ update_query = """
+ UPDATE sag_todo_steps
+ SET is_done = FALSE,
+ completed_by_user_id = NULL,
+ completed_at = NULL
+ WHERE id = %s AND deleted_at IS NULL
+ RETURNING id
+ """
+ execute_query(update_query, (step_id,))
+
+ if data.is_next is not None:
+ if step_row.get("is_done") and data.is_next:
+ raise HTTPException(status_code=400, detail="Completed todo cannot be marked as next")
+
+ if data.is_next:
+ execute_query(
+ """
+ UPDATE sag_todo_steps
+ SET is_next = FALSE
+ WHERE sag_id = %s AND deleted_at IS NULL
+ """,
+ (step_row["sag_id"],)
+ )
+
+ execute_query(
+ """
+ UPDATE sag_todo_steps
+ SET is_next = %s
+ WHERE id = %s AND deleted_at IS NULL
+ """,
+ (bool(data.is_next), step_id)
+ )
+
return execute_query(
"""
SELECT
@@ -552,8 +581,12 @@ async def update_sag(sag_id: int, updates: dict):
updates["status"] = _normalize_case_status(updates.get("status"))
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
+ if "start_date" in updates:
+ updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
+ if "priority" in updates:
+ updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
if "ansvarlig_bruger_id" in updates:
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
_validate_user_id(updates["ansvarlig_bruger_id"])
@@ -569,6 +602,8 @@ async def update_sag(sag_id: int, updates: dict):
"status",
"ansvarlig_bruger_id",
"assigned_group_id",
+ "priority",
+ "start_date",
"deadline",
"deferred_until",
"deferred_until_case_id",
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index 5ba7fd6..5d420f4 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -163,6 +163,28 @@ async def sager_liste(
# Fetch all distinct statuses and tags for filters
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
+ status_options = []
+ seen_statuses = set()
+
+ for row in statuses or []:
+ status_value = str(row.get("status") or "").strip()
+ if not status_value:
+ continue
+ key = status_value.lower()
+ if key in seen_statuses:
+ continue
+ seen_statuses.add(key)
+ status_options.append(status_value)
+
+ current_status = str(sag.get("status") or "").strip()
+ if current_status and current_status.lower() not in seen_statuses:
+ seen_statuses.add(current_status.lower())
+ status_options.append(current_status)
+
+ for default_status in ["åben", "under behandling", "afventer", "løst", "lukket"]:
+ if default_status.lower() not in seen_statuses:
+ seen_statuses.add(default_status.lower())
+ status_options.append(default_status)
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
toggle_include_deferred_url = str(
@@ -475,7 +497,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
- "status_options": [s["status"] for s in statuses],
+ "status_options": status_options,
"is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html
index e46576f..b3291a3 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -1095,34 +1095,669 @@
background: #6c757d;
color: white;
}
+
+ .case-tabs-topbar {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.6rem;
+ background: var(--bg-card);
+ border: 1px solid rgba(0,0,0,0.08);
+ border-radius: 10px;
+ padding: 0.7rem;
+ margin-bottom: 0.75rem;
+ }
+
+ .case-tabs-topbar.topbar-primary {
+ grid-template-columns: 105px minmax(170px, 1.1fr) minmax(170px, 1.1fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr);
+ background: linear-gradient(135deg, rgba(15,76,117,0.12), rgba(15,76,117,0.04));
+ border: 1px solid rgba(15,76,117,0.25);
+ box-shadow: 0 4px 16px rgba(15,76,117,0.12);
+ margin-bottom: 1rem;
+ }
+
+ .topbar-primary .case-tabs-topbar-item {
+ background: transparent;
+ border: none;
+ border-left: 1px solid rgba(15,76,117,0.2);
+ border-radius: 0;
+ padding: 0.45rem 0.7rem;
+ }
+
+ .topbar-primary .case-tabs-topbar-item:first-child {
+ border-left: none;
+ }
+
+ .topbar-primary .case-tabs-topbar-label {
+ color: color-mix(in srgb, var(--accent) 75%, #2f3a45);
+ opacity: 0.95;
+ font-size: 0.58rem;
+ letter-spacing: 0.1em;
+ }
+
+ .topbar-primary .case-tabs-topbar-value {
+ font-size: 1.02rem;
+ font-weight: 750;
+ color: var(--accent);
+ }
+
+ .topbar-primary .case-inline-select {
+ background: rgba(255,255,255,0.78);
+ border-color: rgba(15,76,117,0.3);
+ box-shadow: 0 1px 3px rgba(15,76,117,0.08);
+ font-weight: 700;
+ color: var(--accent);
+ }
+
+ .topbar-primary .topbar-next-meta {
+ margin-top: 0.12rem;
+ font-size: 0.7rem;
+ color: color-mix(in srgb, var(--accent) 55%, #4b5563);
+ opacity: 0.95;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-weight: 700;
+ font-size: 0.86rem;
+ color: var(--accent);
+ text-decoration: none;
+ padding: 0.45rem 0.75rem;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
+ background: color-mix(in srgb, var(--accent) 8%, var(--bg-card));
+ box-shadow: 0 2px 6px rgba(15, 76, 117, 0.08);
+ transition: all 0.16s ease;
+ }
+
+ .back-link:hover {
+ color: color-mix(in srgb, var(--accent) 82%, #0b2438);
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
+ background: color-mix(in srgb, var(--accent) 14%, var(--bg-card));
+ transform: translateY(-1px);
+ box-shadow: 0 6px 14px rgba(15, 76, 117, 0.12);
+ text-decoration: none;
+ }
+
+ [data-bs-theme="dark"] .back-link {
+ color: #b8d9f1;
+ background: rgba(34, 65, 92, 0.42);
+ border-color: rgba(140, 185, 220, 0.34);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+ }
+
+ [data-bs-theme="dark"] .back-link:hover {
+ color: #d9ecfa;
+ border-color: rgba(170, 210, 240, 0.58);
+ background: rgba(46, 87, 124, 0.55);
+ }
+
+ [data-bs-theme="dark"] .case-tabs-topbar.topbar-primary {
+ background: linear-gradient(135deg, rgba(70,120,160,0.2), rgba(70,120,160,0.06));
+ border-color: rgba(120,170,210,0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-primary .case-tabs-topbar-item {
+ border-left-color: rgba(120,170,210,0.25);
+ }
+
+ [data-bs-theme="dark"] .topbar-primary .case-tabs-topbar-label,
+ [data-bs-theme="dark"] .topbar-primary .case-tabs-topbar-value {
+ color: #b8d9f1;
+ }
+
+ [data-bs-theme="dark"] .topbar-primary .case-inline-select {
+ background: rgba(20,35,48,0.7);
+ border-color: rgba(120,170,210,0.4);
+ color: #d9ebf7;
+ }
+
+ .case-tabs-topbar.topbar-secondary {
+ grid-template-columns: repeat(8, minmax(110px, 1fr));
+ }
+
+ .case-tabs-topbar-item {
+ background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
+ border: 1px solid rgba(0,0,0,0.06);
+ border-radius: 8px;
+ padding: 0.55rem 0.65rem;
+ min-width: 0;
+ }
+
+ .case-tabs-topbar-label {
+ font-size: 0.62rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+ opacity: 0.75;
+ margin-bottom: 0.2rem;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ }
+
+ .case-tabs-topbar-value {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .case-tabs-topbar-value.multiline {
+ white-space: normal;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .case-tabs-topbar .case-inline-select {
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ font-size: 0.82rem;
+ padding: 0.32rem 0.55rem;
+ }
+
+ .case-top-aux {
+ flex: 1 1 auto;
+ min-width: 0;
+ width: 100%;
+ }
+
+ .case-top-aux .case-tabs-topbar {
+ background: transparent;
+ border: none;
+ padding: 0;
+ margin-bottom: 0;
+ }
+
+ .case-hero {
+ display: none;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-add {
+ background: #eaf8ee;
+ border-color: #cbe9d4;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-type {
+ background: #e7f3ff;
+ border-color: #c7e2ff;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-created {
+ background: #eefaf1;
+ border-color: #d4f0dc;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-priority {
+ background: #f2ecff;
+ border-color: #ddd0ff;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-start {
+ background: #fff8e8;
+ border-color: #f4e4b6;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-start-before {
+ background: #fff2e8;
+ border-color: #f6d9c1;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-deadline {
+ background: #ffeaea;
+ border-color: #f6c5c5;
+ }
+
+ .topbar-secondary .case-tabs-topbar-item.field-modules {
+ background: #edf2f7;
+ border-color: #d4dde7;
+ }
+
+ .topbar-secondary-action {
+ border: 1px solid rgba(0,0,0,0.12);
+ background: rgba(255,255,255,0.72);
+ color: var(--text-primary);
+ border-radius: 7px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.34rem 0.5rem;
+ text-align: left;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.3rem;
+ line-height: 1.1;
+ }
+
+ .topbar-secondary-action.is-wide {
+ width: 100%;
+ }
+
+ .topbar-secondary-action.is-icon {
+ width: auto;
+ min-width: 40px;
+ justify-content: center;
+ padding: 0.42rem 0.6rem;
+ }
+
+ .topbar-secondary-inline {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ width: 100%;
+ min-width: 0;
+ }
+
+ .topbar-secondary-inline .case-inline-select[type="date"] {
+ flex: 1 1 auto;
+ min-width: 136px;
+ max-width: none;
+ font-variant-numeric: tabular-nums;
+ text-align: left;
+ }
+
+ .topbar-secondary-action:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: rgba(255,255,255,0.95);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-add {
+ background: rgba(32, 120, 72, 0.24);
+ border-color: rgba(92, 194, 132, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-type {
+ background: rgba(80, 120, 200, 0.18);
+ border-color: rgba(140, 180, 255, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-created {
+ background: rgba(40, 120, 80, 0.22);
+ border-color: rgba(90, 200, 140, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-priority {
+ background: rgba(110, 80, 170, 0.24);
+ border-color: rgba(180, 145, 255, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start {
+ background: rgba(150, 120, 40, 0.24);
+ border-color: rgba(230, 190, 90, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before {
+ background: rgba(150, 90, 30, 0.24);
+ border-color: rgba(230, 160, 90, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-deadline {
+ background: rgba(150, 40, 40, 0.24);
+ border-color: rgba(240, 120, 120, 0.35);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-modules {
+ background: rgba(70, 86, 108, 0.28);
+ border-color: rgba(170, 190, 216, 0.3);
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary-action {
+ background: rgba(20, 27, 38, 0.72);
+ border-color: rgba(170, 190, 216, 0.35);
+ color: #dce8f4;
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary-action:hover {
+ border-color: #9fc4e8;
+ color: #9fc4e8;
+ }
+
+ [data-bs-theme="dark"] .topbar-secondary-action.is-icon {
+ background: rgba(20, 27, 38, 0.78);
+ }
+
+ .case-add-side-backdrop {
+ position: fixed;
+ inset: 0;
+ background: transparent;
+ z-index: 1060;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+ }
+
+ .case-add-side-backdrop.open {
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .case-add-side-panel {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: min(460px, 92vw);
+ background: var(--bg-card, #ffffff);
+ border-right: 1px solid rgba(15, 76, 117, 0.16);
+ box-shadow: 8px 0 28px rgba(5, 16, 30, 0.2);
+ z-index: 1061;
+ transform: translateX(-105%);
+ transition: transform 0.22s ease;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .case-add-side-panel.open {
+ transform: translateX(0);
+ }
+
+ .case-add-side-reopen {
+ position: fixed;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 1059;
+ border: 1px solid rgba(15, 76, 117, 0.3);
+ border-left: 0;
+ border-radius: 0 8px 8px 0;
+ background: color-mix(in srgb, var(--accent) 12%, #ffffff);
+ color: var(--accent);
+ font-size: 0.78rem;
+ font-weight: 700;
+ padding: 0.42rem 0.55rem;
+ display: none;
+ }
+
+ .case-add-side-reopen.show {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ }
+
+ .case-add-side-header {
+ padding: 0.8rem 0.95rem;
+ border-bottom: 1px solid rgba(15, 76, 117, 0.14);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ }
+
+ .case-add-side-title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ }
+
+ .case-add-side-body {
+ padding: 0.85rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.7rem;
+ height: 100%;
+ min-height: 0;
+ }
+
+ .case-add-module-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.45rem;
+ }
+
+ .case-add-module-btn {
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 8px;
+ background: #f8fbff;
+ color: var(--text-primary);
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.4rem 0.5rem;
+ text-align: left;
+ }
+
+ .case-add-module-btn.active {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 14%, white);
+ color: var(--accent);
+ }
+
+ .case-add-workspace {
+ border: 1px solid rgba(15, 76, 117, 0.14);
+ border-radius: 10px;
+ padding: 0.8rem;
+ background: #fbfdff;
+ overflow-y: auto;
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+
+ .case-add-workspace .section-title {
+ font-size: 0.86rem;
+ font-weight: 700;
+ margin-bottom: 0.55rem;
+ color: var(--text-primary);
+ }
+
+ .case-add-workspace .list-group-item {
+ border-radius: 6px;
+ margin-bottom: 0.3rem;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ }
+
+ [data-bs-theme="dark"] .case-add-side-panel {
+ background: #0f1723;
+ border-right-color: rgba(166, 200, 235, 0.25);
+ box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
+ }
+
+ [data-bs-theme="dark"] .case-add-side-header {
+ border-bottom-color: rgba(166, 200, 235, 0.2);
+ }
+
+ [data-bs-theme="dark"] .case-add-module-btn {
+ background: rgba(18, 34, 53, 0.85);
+ border-color: rgba(170, 190, 216, 0.25);
+ color: #d8e5f3;
+ }
+
+ [data-bs-theme="dark"] .case-add-module-btn.active {
+ background: rgba(44, 107, 162, 0.3);
+ border-color: #8ec1ee;
+ color: #b7d8f4;
+ }
+
+ [data-bs-theme="dark"] .case-add-workspace {
+ background: rgba(13, 24, 38, 0.9);
+ border-color: rgba(166, 200, 235, 0.2);
+ }
+
+ [data-bs-theme="dark"] .case-add-side-reopen {
+ background: rgba(37, 91, 136, 0.3);
+ color: #b5d7f3;
+ border-color: rgba(142, 193, 238, 0.4);
+ }
+
+ @media (max-width: 1200px) {
+ .case-tabs-topbar {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .topbar-primary .case-tabs-topbar-item {
+ border-left: none;
+ border-top: 1px solid rgba(15,76,117,0.2);
+ }
+
+ .topbar-primary .case-tabs-topbar-item:nth-child(-n+2) {
+ border-top: none;
+ }
+ }
+
+ @media (max-width: 768px) {
+ .case-tabs-topbar {
+ grid-template-columns: 1fr;
+ }
+ }
{% endblock %}
{% block content %}
-
+
+
+
+
SagsID
+
#{{ case.id }}
+
+
+
Firma
+
{{ customer.name if customer else 'Ingen kunde' }}
+
+
+
Kontakt
+
{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}
+
+
+
Status
+
+
+
+
Ansvarlig
+
+
+
+
Gruppe
+
+
+
+
Næste
+
Henter næste todo...
+
-
+
+
+
-
+
Tilbage til sager
-
-
-
-
+
+
+
+
+
+
Tilføj
+
+
+
+
Type
+
+
+
+
Prioritet
+
+
+
+
Oprettelses dato
+
{{ case.created_at.strftime('%d/%m/%Y') if case.created_at else '-' }}
+
+
+
Start arbejde
+
+
+
+
+
+
+
Start inden denne dato
+
+
{{ case.deferred_until.strftime('%d/%m/%Y') if case.deferred_until else '-' }}
+
+
+
+
+
Deadline dato
+
+
{{ case.deadline.strftime('%d/%m/%Y') if case.deadline else '-' }}
+
+
+
+
+
Moduler
+
+
-
-
@@ -1332,10 +1967,10 @@
-
+
-
+
-
+
@@ -2082,6 +2717,7 @@
'locations': 'Lokationer',
'contacts': 'Kontakter',
'customers': 'Kunder',
+ 'tags': 'Tags',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
@@ -2135,6 +2771,11 @@
loadCaseLocations();
loadCaseWiki();
loadTodoSteps();
+ loadCaseTagsModule();
+ loadCaseTagSuggestions();
+
+ // Keep suggestions fresh while user works on the case.
+ setInterval(loadCaseTagSuggestions, 30000);
const wikiSearchInput = document.getElementById('wikiSearchInput');
if (wikiSearchInput) {
@@ -2776,7 +3417,21 @@
async function loadCaseHardware() {
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`);
+ if (!res.ok) {
+ let message = 'Kunne ikke hente hardware.';
+ try {
+ const err = await res.json();
+ if (err?.detail) {
+ message = err.detail;
+ }
+ } catch (_) {
+ }
+ throw new Error(message);
+ }
const hardware = await res.json();
+ if (!Array.isArray(hardware)) {
+ throw new Error('Uventet svar fra serveren ved hardware-hentning.');
+ }
const container = document.getElementById('hardware-list');
if (hardware.length === 0) {
@@ -2808,7 +3463,8 @@
setModuleContentState('hardware', true);
} catch (e) {
console.error("Error loading hardware:", e);
- document.getElementById('hardware-list').innerHTML = '
Fejl ved hentning
';
+ const message = (e?.message || '').trim() || 'Fejl ved hentning';
+ document.getElementById('hardware-list').innerHTML = `
${escapeHtml(message)}
`;
setModuleContentState('hardware', true);
}
}
@@ -2845,7 +3501,21 @@
async function loadCaseLocations() {
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`);
+ if (!res.ok) {
+ let message = 'Kunne ikke hente lokationer.';
+ try {
+ const err = await res.json();
+ if (err?.detail) {
+ message = err.detail;
+ }
+ } catch (_) {
+ }
+ throw new Error(message);
+ }
const locations = await res.json();
+ if (!Array.isArray(locations)) {
+ throw new Error('Uventet svar fra serveren ved lokations-hentning.');
+ }
const container = document.getElementById('locations-list');
if (locations.length === 0) {
@@ -2876,7 +3546,8 @@
setModuleContentState('locations', true);
} catch (e) {
console.error("Error loading locations:", e);
- document.getElementById('locations-list').innerHTML = '
Fejl ved hentning
';
+ const message = (e?.message || '').trim() || 'Fejl ved hentning';
+ document.getElementById('locations-list').innerHTML = `
${escapeHtml(message)}
`;
setModuleContentState('locations', true);
}
}
@@ -2941,6 +3612,109 @@
}
}
+ async function loadCaseTagsModule() {
+ const moduleContainer = document.getElementById('case-tags-module');
+ if (!moduleContainer) return;
+
+ try {
+ const response = await fetch(`/api/v1/tags/entity/case/${caseId}`);
+ if (!response.ok) throw new Error('Kunne ikke hente tags');
+
+ const tags = await response.json();
+ if (!Array.isArray(tags) || tags.length === 0) {
+ moduleContainer.innerHTML = '
Ingen tags paaa sagen endnu
';
+ setModuleContentState('tags', false);
+ return;
+ }
+
+ moduleContainer.innerHTML = tags.map((tag) => `
+
+ ${tag.icon ? ` ` : ''}${escapeHtml(tag.name)}
+
+
+ `).join('');
+
+ setModuleContentState('tags', true);
+ } catch (error) {
+ console.error('Error loading case tags module:', error);
+ moduleContainer.innerHTML = '
Fejl ved hentning af tags
';
+ setModuleContentState('tags', true);
+ }
+ }
+
+ async function loadCaseTagSuggestions() {
+ const suggestionsContainer = document.getElementById('case-tag-suggestions');
+ if (!suggestionsContainer) return;
+
+ try {
+ const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`);
+ if (!response.ok) throw new Error('Kunne ikke hente forslag');
+
+ const suggestions = await response.json();
+ if (!Array.isArray(suggestions) || suggestions.length === 0) {
+ suggestionsContainer.innerHTML = '
Ingen nye forslag lige nu
';
+ return;
+ }
+
+ suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => {
+ const tag = item.tag || {};
+ const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
+ return `
+
+
+
+ ${tag.icon ? ` ` : ''}${escapeHtml(tag.name || 'Tag')}
+
+ ${matched ? `
Match: ${escapeHtml(matched)}
` : ''}
+
+
+
+ `;
+ }).join('');
+ } catch (error) {
+ console.error('Error loading tag suggestions:', error);
+ suggestionsContainer.innerHTML = '
Fejl ved forslag
';
+ }
+ }
+
+ async function applySuggestedCaseTag(tagId) {
+ if (!tagId) return;
+ try {
+ const response = await fetch('/api/v1/tags/entity', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke tilfoeje tag');
+ }
+
+ await syncCaseTagsUi();
+ if (typeof showNotification === 'function') {
+ showNotification('Tag tilfoejet', 'success');
+ }
+ } catch (error) {
+ alert('Fejl: ' + error.message);
+ }
+ }
+
+ async function removeCaseTagAndSync(tagId) {
+ await window.removeEntityTag('case', caseId, tagId, 'case-tags-module');
+ await syncCaseTagsUi();
+ }
+
+ async function syncCaseTagsUi() {
+ if (window.renderEntityTags) {
+ await window.renderEntityTags('case', caseId, 'case-tags');
+ }
+ await loadCaseTagsModule();
+ await loadCaseTagSuggestions();
+ }
+
let todoUserId = null;
function getTodoUserId() {
@@ -2976,10 +3750,31 @@
return date.toLocaleString('da-DK', { hour: '2-digit', minute: '2-digit', hour12: false });
}
+ function getNextTodoOverrideStorageKey() {
+ return `case:${caseId}:nextTodoStepId`;
+ }
+
+ function getNextTodoOverrideId() {
+ const raw = localStorage.getItem(getNextTodoOverrideStorageKey());
+ const parsed = Number(raw);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+ }
+
+ function setNextTodoOverrideId(stepIdOrNull) {
+ const key = getNextTodoOverrideStorageKey();
+ if (stepIdOrNull === null || stepIdOrNull === undefined) {
+ localStorage.removeItem(key);
+ return;
+ }
+ localStorage.setItem(key, String(stepIdOrNull));
+ }
+
function renderTodoSteps(steps) {
const list = document.getElementById('todo-steps-list');
if (!list) return;
+ updateTopbarNextTodo(steps || []);
+
const escapeAttr = (value) => String(value ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"')
@@ -2994,6 +3789,7 @@
const openSteps = steps.filter(step => !step.is_done);
const doneSteps = steps.filter(step => step.is_done);
+ const nextOverrideId = getNextTodoOverrideId();
const renderStep = (step) => {
const createdBy = step.created_by_name || 'Ukendt';
@@ -3001,15 +3797,19 @@
const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-';
const createdLabel = formatTodoDateTime(step.created_at);
const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null;
+ const isNextEffective = !step.is_done && (!!step.is_next || (nextOverrideId !== null && step.id === nextOverrideId));
const statusBadge = step.is_done
? '
Færdig'
- : '
Åben';
+ : `
${isNextEffective ? 'Næste' : 'Åben'}`;
const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig';
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
+ const nextLabel = isNextEffective ? 'Fjern som næste' : 'Sæt som næste';
+ const nextClass = isNextEffective ? 'btn-primary' : 'btn-outline-primary';
const tooltipText = [
`Oprettet af: ${createdBy}`,
`Oprettet: ${createdLabel}`,
`Forfald: ${dueLabel}`,
+ isNextEffective ? 'Markeret som næste opgave' : null,
step.is_done && completedLabel ? `Færdiggjort af: ${completedBy}` : null,
step.is_done && completedLabel ? `Færdiggjort: ${completedLabel}` : null
].filter(Boolean).join('
');
@@ -3026,6 +3826,11 @@
${statusBadge}
+ ${!step.is_done ? `
+
+ ` : ''}
@@ -3071,6 +3876,52 @@
setModuleContentState('todo-steps', true);
}
+ function updateTopbarNextTodo(steps) {
+ const valueEl = document.getElementById('topbarNextTodoValue');
+ const metaEl = document.getElementById('topbarNextTodoMeta');
+ if (!valueEl || !metaEl) return;
+
+ const openSteps = Array.isArray(steps) ? steps.filter((step) => !step.is_done) : [];
+ if (!openSteps.length) {
+ valueEl.textContent = 'Ingen åbne todo-opgaver';
+ metaEl.textContent = 'Alt er færdigt';
+ setNextTodoOverrideId(null);
+ return;
+ }
+
+ const nextOverrideId = getNextTodoOverrideId();
+ const overrideStep = nextOverrideId ? openSteps.find((step) => step.id === nextOverrideId) : null;
+ const nextStep = overrideStep || openSteps.find((step) => !!step.is_next) || openSteps[0];
+
+ if (!overrideStep && nextOverrideId) {
+ setNextTodoOverrideId(null);
+ }
+
+ valueEl.textContent = nextStep.title || 'Untitled todo';
+ metaEl.textContent = nextStep.due_date
+ ? `Forfald: ${formatTodoDate(nextStep.due_date)}`
+ : 'Ingen forfaldsdato';
+ }
+
+ async function setNextTodoStep(stepId, isNext) {
+ try {
+ const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_next: isNext, is_done: false })
+ });
+ if (!res.ok) {
+ const error = await res.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke opdatere næste-opgave');
+ }
+
+ setNextTodoOverrideId(isNext ? stepId : null);
+ await loadTodoSteps();
+ } catch (e) {
+ alert('Fejl: ' + e.message);
+ }
+ }
+
async function loadTodoSteps() {
const list = document.getElementById('todo-steps-list');
if (!list) return;
@@ -3376,7 +4227,7 @@
-
+
+
+
+
+
+
+
Forslag (brand/type)
+
+
+
+
+
@@ -5640,6 +6513,25 @@
{% endif %}
+
+
+
+
@@ -5717,6 +6609,162 @@
let currentSearchType = null;
let searchDebounceIds = null;
const caseIds = {{ case.id }};
+ const currentCaseTitle = {{ (case.titel or '') | tojson }};
+ let caseAddPanelInitialized = false;
+ let caseAddActiveAction = null;
+ let caseAddOriginalShowRelModal = null;
+ const CASE_ADD_ACTIONS = [
+ { action: 'assign', label: 'Tildel sag', icon: 'bi-person-check', moduleKey: null, relFn: 'openRelAssignModal' },
+ { action: 'time', label: 'Tidregistrering', icon: 'bi-clock', moduleKey: 'time', relFn: 'openRelTimeModal' },
+ { action: 'note', label: 'Kommentar', icon: 'bi-chat-left-text', moduleKey: 'solution', relFn: 'openRelNoteModal' },
+ { action: 'reminder', label: 'Pamindelse', icon: 'bi-bell', moduleKey: 'reminders', relFn: 'openRelReminderModal' },
+ { action: 'pipeline', label: 'Salgspipeline', icon: 'bi-graph-up-arrow', moduleKey: 'pipeline', relFn: 'openRelPipelineModal' },
+ { action: 'files', label: 'Filer', icon: 'bi-paperclip', moduleKey: 'files', relFn: 'openRelFilesModal' },
+ { action: 'hardware', label: 'Hardware', icon: 'bi-cpu', moduleKey: 'hardware', relFn: 'openRelHardwareModal' },
+ { action: 'todo', label: 'Opgave', icon: 'bi-check2-square', moduleKey: 'todo-steps', relFn: 'openRelTodoModal' },
+ { action: 'solution', label: 'Losning', icon: 'bi-lightbulb', moduleKey: 'solution', relFn: 'openRelSolutionModal' },
+ { action: 'sales', label: 'Varekob og salg', icon: 'bi-bag', moduleKey: 'sales', relFn: 'openRelSalesModal' },
+ { action: 'subscription', label: 'Abonnement', icon: 'bi-arrow-repeat', moduleKey: 'subscription', relFn: 'openRelSubscriptionModal' },
+ { action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
+ ];
+
+ function isCaseAddActionActive(actionConfig) {
+ if (!actionConfig.moduleKey) return true;
+ if (actionConfig.moduleKey === 'time') return true;
+ return modulePrefs[actionConfig.moduleKey] !== false;
+ }
+
+ async function openCaseModuleAddPanel() {
+ if (typeof loadModulePrefs === 'function') {
+ await loadModulePrefs();
+ }
+
+ const panel = document.getElementById('caseAddSidePanel');
+ const backdrop = document.getElementById('caseAddSideBackdrop');
+ const reopen = document.getElementById('caseAddSideReopen');
+ if (!panel || !backdrop || !reopen) return;
+
+ backdrop.classList.add('open');
+ panel.classList.add('open');
+ panel.setAttribute('aria-hidden', 'false');
+ reopen.classList.remove('show');
+
+ if (!caseAddOriginalShowRelModal && typeof window._showRelModal === 'function') {
+ caseAddOriginalShowRelModal = window._showRelModal;
+ }
+ if (typeof caseAddOriginalShowRelModal === 'function') {
+ window._showRelModal = renderCaseAddWorkspaceModal;
+ }
+
+ if (!caseAddPanelInitialized) {
+ renderCaseAddActionList();
+ caseAddPanelInitialized = true;
+ }
+ }
+
+ function closeCaseModuleAddPanel() {
+ const panel = document.getElementById('caseAddSidePanel');
+ const backdrop = document.getElementById('caseAddSideBackdrop');
+ const reopen = document.getElementById('caseAddSideReopen');
+ if (!panel || !backdrop || !reopen) return;
+
+ panel.classList.remove('open');
+ panel.setAttribute('aria-hidden', 'true');
+ backdrop.classList.remove('open');
+ reopen.classList.add('show');
+
+ if (typeof caseAddOriginalShowRelModal === 'function') {
+ window._showRelModal = caseAddOriginalShowRelModal;
+ }
+ }
+
+ function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
+ const workspace = document.getElementById('caseAddSideWorkspace');
+ if (!workspace) return;
+
+ workspace.innerHTML = `
+
+
${title}
+
${bodyHtml}
+
+
+ `;
+
+ workspace.querySelectorAll('form').forEach((formEl) => {
+ formEl.addEventListener('submit', (evt) => evt.preventDefault());
+ });
+
+ workspace.querySelectorAll('#relQaModalFooter button').forEach((btnEl) => {
+ if (!btnEl.getAttribute('type')) {
+ btnEl.setAttribute('type', 'button');
+ }
+ });
+ }
+
+ function renderCaseAddActionList() {
+ const listEl = document.getElementById('caseAddModuleList');
+ if (!listEl) return;
+
+ const actions = CASE_ADD_ACTIONS.filter((cfg) => isCaseAddActionActive(cfg));
+ if (!actions.length) {
+ listEl.innerHTML = '
Ingen aktive moduler fundet.
';
+ return;
+ }
+
+ listEl.innerHTML = actions.map((cfg) => `
+
+ `).join('');
+
+ openCaseAddAction(actions[0].action);
+ }
+
+ async function openCaseAddAction(actionName) {
+ document.querySelectorAll('.case-add-module-btn').forEach((btn) => btn.classList.remove('active'));
+ document.getElementById(`caseAddAction_${actionName}`)?.classList.add('active');
+ caseAddActiveAction = actionName;
+
+ const action = CASE_ADD_ACTIONS.find((cfg) => cfg.action === actionName);
+ const workspace = document.getElementById('caseAddSideWorkspace');
+ if (!action || !workspace) return;
+
+ workspace.innerHTML = '
Indlaeser formular...
';
+
+ const relFn = window[action.relFn];
+ if (typeof relFn !== 'function') {
+ workspace.innerHTML = '
Modulformular er ikke tilgaengelig endnu.
';
+ return;
+ }
+
+ const existingRelQaEl = document.getElementById('relQaModalEl');
+ if (existingRelQaEl && !workspace.contains(existingRelQaEl)) {
+ const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
+ if (existingModalInstance) {
+ existingModalInstance.hide();
+ }
+ existingRelQaEl.remove();
+ }
+
+ try {
+ await Promise.resolve(relFn(caseIds, currentCaseTitle));
+ } catch (error) {
+ console.error('Could not load module add form', error);
+ workspace.innerHTML = '
Kunne ikke indlaese formularen.
';
+ }
+ }
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ const panel = document.getElementById('caseAddSidePanel');
+ if (panel && panel.classList.contains('open')) {
+ closeCaseModuleAddPanel();
+ }
+ }
+ });
function openSearchModal(type) {
currentSearchType = type;
@@ -6118,6 +7166,107 @@
}
}
+ async function saveCaseStatusFromTopbar() {
+ const select = document.getElementById('topbarStatusSelect');
+ if (!select) return;
+
+ try {
+ const response = await fetch(`/api/v1/sag/${caseId}`, {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: select.value || 'åben' })
+ });
+
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ location.reload();
+ } catch (e) {
+ console.error('saveCaseStatusFromTopbar error', e);
+ showToast('Kunne ikke gemme status', 'danger');
+ }
+ }
+
+ function saveCaseTypeFromTopbar() {
+ const select = document.getElementById('topbarTypeSelect');
+ if (!select) return;
+
+ const typeMeta = {
+ ticket: { label: 'Ticket', icon: 'bi-ticket-perforated', color: '#6366f1' },
+ pipeline: { label: 'Pipeline', icon: 'bi-graph-up-arrow', color: '#0ea5e9' },
+ opgave: { label: 'Opgave', icon: 'bi-puzzle', color: '#f59e0b' },
+ ordre: { label: 'Ordre', icon: 'bi-receipt', color: '#10b981' },
+ projekt: { label: 'Projekt', icon: 'bi-folder2-open', color: '#8b5cf6' },
+ service: { label: 'Service', icon: 'bi-tools', color: '#ef4444' }
+ };
+
+ const nextType = (select.value || 'ticket').toLowerCase();
+ const meta = typeMeta[nextType] || typeMeta.ticket;
+ saveCaseType(nextType, meta.label, meta.icon, meta.color);
+ }
+
+ async function saveCasePriorityFromTopbar() {
+ const select = document.getElementById('topbarPrioritySelect');
+ if (!select) return;
+
+ try {
+ const resp = await fetch(`/api/v1/sag/${caseId}`, {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ priority: (select.value || 'normal').toLowerCase() })
+ });
+
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ location.reload();
+ } catch (e) {
+ console.error('saveCasePriorityFromTopbar error', e);
+ showToast('Kunne ikke gemme prioritet', 'danger');
+ }
+ }
+
+ async function saveCaseStartDateFromTopbar() {
+ const input = document.getElementById('topbarStartDateInput');
+ if (!input) return;
+
+ try {
+ const resp = await fetch(`/api/v1/sag/${caseId}`, {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ start_date: input.value || null })
+ });
+
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ location.reload();
+ } catch (e) {
+ console.error('saveCaseStartDateFromTopbar error', e);
+ showToast('Kunne ikke gemme startdato', 'danger');
+ }
+ }
+
+ function clearCaseStartDateFromTopbar() {
+ const input = document.getElementById('topbarStartDateInput');
+ if (!input) return;
+ input.value = '';
+ saveCaseStartDateFromTopbar();
+ }
+
+ async function saveAssignmentFromTabsBar() {
+ const topUser = document.getElementById('tabsAssignmentUserSelect');
+ const topGroup = document.getElementById('tabsAssignmentGroupSelect');
+ const legacyUser = document.getElementById('assignmentUserSelect');
+ const legacyGroup = document.getElementById('assignmentGroupSelect');
+
+ if (legacyUser && topUser) {
+ legacyUser.value = topUser.value;
+ }
+ if (legacyGroup && topGroup) {
+ legacyGroup.value = topGroup.value;
+ }
+
+ await saveAssignment();
+ }
+
async function saveAssignment() {
const statusEl = document.getElementById('assignmentStatus');
const userValue = document.getElementById('assignmentUserSelect')?.value || '';
@@ -6157,6 +7306,15 @@
if (statusEl) {
statusEl.textContent = '✅ Tildeling gemt';
}
+
+ const topUser = document.getElementById('tabsAssignmentUserSelect');
+ const topGroup = document.getElementById('tabsAssignmentGroupSelect');
+ if (topUser) {
+ topUser.value = userValue;
+ }
+ if (topGroup) {
+ topGroup.value = groupValue;
+ }
} catch (err) {
if (statusEl) {
statusEl.textContent = `❌ ${err.message}`;
@@ -6235,8 +7393,8 @@
const viewDefaults = {
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
- 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki'],
- 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
+ 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'],
+ 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
};
const defaultsByCaseType = caseTypeModuleDefaults[caseTypeKey];
@@ -6244,6 +7402,8 @@
? defaultsByCaseType
: (viewDefaults[viewName] || []);
const standardModuleSet = new Set(standardModules);
+ standardModuleSet.add('tags');
+ standardModuleSet.add('time');
document.querySelectorAll('[data-module]').forEach((el) => {
const moduleName = el.getAttribute('data-module');
@@ -6328,12 +7488,16 @@
const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)');
if (visibleRightModules.length === 0) {
rightColumn.classList.add('d-none');
+ rightColumn.classList.remove('col-xl-4');
rightColumn.classList.remove('col-lg-4');
+ leftColumn.classList.remove('col-xl-8');
leftColumn.classList.remove('col-lg-8');
leftColumn.classList.add('col-12');
} else {
rightColumn.classList.remove('d-none');
+ rightColumn.classList.add('col-xl-4');
rightColumn.classList.add('col-lg-4');
+ leftColumn.classList.add('col-xl-8');
leftColumn.classList.add('col-lg-8');
leftColumn.classList.remove('col-12');
}
@@ -6349,15 +7513,13 @@
const hasVisibleLeft = visibleLeftModules.length > 0;
if (!hasVisibleLeft) {
- // Ingen synlige moduler i venstre - udvid center til fuld bredde
+ // Ingen synlige moduler i venstre - center forbliver fuld bredde
leftCol.classList.add('d-none');
- centerCol.classList.remove('col-xl-8');
- centerCol.classList.add('col-xl-12');
+ centerCol.classList.add('col-12');
} else {
- // Gendan 4/8 split
+ // Begge interne sektioner vises stadig i én kolonne hver
leftCol.classList.remove('d-none');
- centerCol.classList.remove('col-xl-12');
- centerCol.classList.add('col-xl-8');
+ centerCol.classList.add('col-12');
}
}
@@ -7877,6 +9039,30 @@
+ extra;
};
+ function getRelQaPrimaryButton() {
+ const sidePanel = document.getElementById('caseAddSidePanel');
+ if (sidePanel && sidePanel.classList.contains('open')) {
+ return sidePanel.querySelector('#relQaModalFooter .btn-primary');
+ }
+ return document.querySelector('#relQaModalEl .btn-primary');
+ }
+
+ function closeRelQaSurfaceAfterSave() {
+ const sidePanel = document.getElementById('caseAddSidePanel');
+ const panelOpen = !!(sidePanel && sidePanel.classList.contains('open'));
+
+ const relModalEl = document.getElementById('relQaModalEl');
+ const relModalInstance = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
+ if (relModalInstance) {
+ relModalInstance.hide();
+ }
+
+ // In sidepanel mode, refresh to reflect new persisted data across modules.
+ if (panelOpen) {
+ setTimeout(() => window.location.reload(), 120);
+ }
+ }
+
window.relQaAction = function(action, caseId, caseTitle) {
closeAllPopovers();
if (action === 'time') openRelTimeModal(caseId, caseTitle);
@@ -7944,12 +9130,12 @@
if (prob) payload.probability = parseInt(prob);
if (desc) payload.description = desc;
if (!Object.keys(payload).length) { if (typeof showNotification === 'function') showNotification('Udfyld mindst ét felt', 'warning'); return; }
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}/pipeline`, { method: 'PATCH', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Pipeline opdateret ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -7979,7 +9165,7 @@
window._submitRelFiles = async function(caseId) {
const fileInput = document.getElementById('rqf_file');
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '
Uploader…'; }
let success = 0; let failed = 0;
for (const file of fileInput.files) {
@@ -7992,7 +9178,7 @@
if (r.ok) success++; else failed++;
} catch { failed++; }
}
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') {
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success');
else showNotification(`${success} ok, ${failed} fejlede`, 'warning');
@@ -8049,7 +9235,7 @@
window._submitRelHardware = async function(caseId) {
const hwId = document.getElementById('rqhw_id').value;
if (!hwId) { if (typeof showNotification === 'function') showNotification('Vælg hardware fra listen', 'warning'); return; }
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch(`/api/v1/sag/${caseId}/hardware`, {
@@ -8057,7 +9243,7 @@
body: JSON.stringify({ hardware_id: parseInt(hwId), note: document.getElementById('rqhw_note').value })
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Hardware tilknyttet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8105,7 +9291,7 @@
window._submitRelSolution = async function(caseId) {
const title = document.getElementById('rqs_title').value.trim();
if (!title) { if (typeof showNotification === 'function') showNotification('Angiv en titel', 'warning'); return; }
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch(`/api/v1/sag/${caseId}/solution`, {
@@ -8119,7 +9305,7 @@
})
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Løsning gemt ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8186,7 +9372,7 @@
const total = parseFloat(document.getElementById('rqsl_total').value);
if (!desc) { if (typeof showNotification === 'function') showNotification('Angiv beskrivelse', 'warning'); return; }
if (!total) { if (typeof showNotification === 'function') showNotification('Angiv beløb', 'warning'); return; }
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch(`/api/v1/sag/${caseId}/sale-items`, {
@@ -8202,7 +9388,7 @@
})
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Varelinje oprettet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8262,7 +9448,7 @@
const liPrice = parseFloat(document.getElementById('rqsub_li_price').value) || 0;
if (!startDate) { if (typeof showNotification === 'function') showNotification('Angiv startdato', 'warning'); return; }
if (!liDesc || !liPrice) { if (typeof showNotification === 'function') showNotification('Udfyld varelinje (beskrivelse + pris)', 'warning'); return; }
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) saveBtn.disabled = true;
try {
const r = await fetch('/api/v1/sag-subscriptions', {
@@ -8277,7 +9463,7 @@
})
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Abonnement oprettet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8331,12 +9517,12 @@
billing_method: billing,
is_internal: billing === 'internal',
};
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '
'; }
try {
const r = await fetch('/api/v1/timetracking/entries/internal', { method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Tid registreret ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8348,16 +9534,135 @@
// ── Quick Email modal ─────────────────────────────────────────────
window.openRelEmailModal = function(caseId, caseTitle) {
- // Delegate to existing email module if present
- if (typeof openEmailModal === 'function') { openEmailModal(caseId); return; }
- if (typeof openEmailCompose === 'function') { openEmailCompose({ sagId: caseId }); return; }
+ const defaultRecipient = typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : '';
+ const defaultSubject = `Sag #${caseId}: `;
+ const attachmentOptions = Array.isArray(sagFilesCache) && sagFilesCache.length
+ ? sagFilesCache
+ .map((file) => {
+ const fileId = Number(file.id);
+ const filename = esc(file.filename || `Fil ${fileId}`);
+ return `
`;
+ })
+ .join('')
+ : '
';
+
_showRelModal(
`
Email`,
- `
Åbn SAG-${caseId} for at sende email direkte fra sagens email-modul.
`,
- ``
+ `
+
+
+
+
+
+
+
+
+
`,
+ `
`
);
};
+ window._submitRelEmail = async function(caseId) {
+ const toInput = document.getElementById('rqe_to');
+ const ccInput = document.getElementById('rqe_cc');
+ const bccInput = document.getElementById('rqe_bcc');
+ const subjectInput = document.getElementById('rqe_subject');
+ const bodyInput = document.getElementById('rqe_body');
+ const attachmentSelect = document.getElementById('rqe_attachment_ids');
+ const statusEl = document.getElementById('rqe_status');
+ const saveBtn = getRelQaPrimaryButton();
+
+ if (!toInput || !subjectInput || !bodyInput || !statusEl) return;
+
+ const to = parseEmailField(toInput.value);
+ const cc = parseEmailField(ccInput?.value || '');
+ const bcc = parseEmailField(bccInput?.value || '');
+ const subject = (subjectInput.value || '').trim();
+ const bodyText = (bodyInput.value || '').trim();
+ const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || [])
+ .map((opt) => Number(opt.value))
+ .filter((id) => Number.isInteger(id) && id > 0);
+
+ if (!to.length) {
+ if (typeof showNotification === 'function') showNotification('Udfyld mindst en modtager.', 'warning');
+ return;
+ }
+ if (!subject) {
+ if (typeof showNotification === 'function') showNotification('Udfyld emne.', 'warning');
+ return;
+ }
+ if (!bodyText) {
+ if (typeof showNotification === 'function') showNotification('Udfyld besked.', 'warning');
+ return;
+ }
+
+ if (saveBtn) {
+ saveBtn.disabled = true;
+ saveBtn.innerHTML = '
Sender...';
+ }
+ statusEl.className = 'small text-muted';
+ statusEl.textContent = 'Sender e-mail...';
+
+ try {
+ const res = await fetch(`/api/v1/sag/${caseId}/emails/send`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ to,
+ cc,
+ bcc,
+ subject,
+ body_text: bodyText,
+ attachment_file_ids: attachmentFileIds,
+ thread_email_id: selectedLinkedEmailId || null,
+ thread_key: linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key || null
+ })
+ });
+
+ if (!res.ok) {
+ let message = 'Kunne ikke sende e-mail.';
+ try {
+ const err = await res.json();
+ if (err?.detail) message = err.detail;
+ } catch (_) {
+ }
+ throw new Error(message);
+ }
+
+ statusEl.className = 'small text-success';
+ statusEl.textContent = 'E-mail sendt.';
+ if (typeof loadLinkedEmails === 'function') {
+ loadLinkedEmails();
+ }
+ if (typeof showNotification === 'function') showNotification('E-mail sendt.', 'success');
+
+ const relModalEl = document.getElementById('relQaModalEl');
+ const relModal = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
+ if (relModal) relModal.hide();
+ } catch (error) {
+ statusEl.className = 'small text-danger';
+ statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.';
+ if (typeof showNotification === 'function') showNotification(statusEl.textContent, 'error');
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ saveBtn.innerHTML = '
Send email';
+ }
+ return;
+ }
+
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ saveBtn.innerHTML = '
Send email';
+ }
+ };
+
// ── Quick Kommentar modal ─────────────────────────────────────────
window.openRelNoteModal = function(caseId, caseTitle) {
_showRelModal(
@@ -8380,7 +9685,7 @@
body: JSON.stringify({ forfatter: 'Hurtig kommentar', indhold: text })
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Kommentar tilføjet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8408,7 +9713,7 @@
const title = document.getElementById('rqtd_title').value.trim();
if (!title) { if (typeof showNotification === 'function') showNotification('Angiv opgavetitel', 'warning'); return; }
const due = document.getElementById('rqtd_due').value || null;
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}/todos`, {
@@ -8417,7 +9722,7 @@
body: JSON.stringify({ titel: title, frist: due, sag_id: caseId })
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8449,7 +9754,7 @@
window._submitRelAssign = async function(caseId) {
const userId = document.getElementById('rqa_user')?.value;
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch(`/api/v1/sag/${caseId}`, {
@@ -8458,7 +9763,7 @@
body: JSON.stringify({ ansvarlig_bruger_id: userId ? parseInt(userId) : null })
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Sag tildelt ✓', 'success');
} else {
const d = await r.json().catch(()=>({}));
@@ -8485,7 +9790,7 @@
window._submitRelReminder = async function(caseId) {
const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value };
- const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
+ const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch('/api/v1/reminders', {
@@ -8494,7 +9799,7 @@
body: JSON.stringify(payload)
});
if (r.ok) {
- bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
+ closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Påmindelse oprettet', 'success');
} else { if (saveBtn) saveBtn.disabled = false; }
} catch { if (saveBtn) saveBtn.disabled = false; }
diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html
index 4dfd82d..a777dce 100644
--- a/app/modules/sag/templates/index.html
+++ b/app/modules/sag/templates/index.html
@@ -17,12 +17,14 @@
.table-wrapper {
background: var(--bg-card);
border-radius: 12px;
- overflow: hidden;
+ overflow-x: auto;
+ overflow-y: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.sag-table {
width: 100%;
+ min-width: 1550px;
margin: 0;
}
@@ -32,12 +34,13 @@
}
.sag-table thead th {
- padding: 0.8rem 1rem;
+ padding: 0.6rem 0.75rem;
font-weight: 600;
- font-size: 0.85rem;
+ font-size: 0.78rem;
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.3px;
border: none;
+ white-space: nowrap;
}
.sag-table tbody tr {
@@ -51,9 +54,30 @@
}
.sag-table tbody td {
- padding: 0.6rem 1rem;
- vertical-align: middle;
- font-size: 0.9rem;
+ padding: 0.5rem 0.75rem;
+ vertical-align: top;
+ font-size: 0.86rem;
+ white-space: nowrap;
+ }
+
+ .sag-table td.col-company,
+ .sag-table td.col-contact,
+ .sag-table td.col-owner,
+ .sag-table td.col-group,
+ .sag-table td.col-desc {
+ white-space: normal;
+ }
+
+ .sag-table td.col-company,
+ .sag-table td.col-contact,
+ .sag-table td.col-owner,
+ .sag-table td.col-group {
+ max-width: 180px;
+ }
+
+ .sag-table td.col-desc {
+ min-width: 260px;
+ max-width: 360px;
}
.sag-id {
@@ -246,7 +270,7 @@
{% endblock %}
{% block content %}
-
+
@@ -330,17 +354,18 @@
- | ID |
- Titel & Beskrivelse |
+ SagsID |
+ Virksom. |
+ Kontakt |
+ Beskr. |
Type |
- Kunde |
- Hovedkontakt |
- Ansvarlig |
- Gruppe |
- Status |
- Udsat start |
- Oprettet |
- Opdateret |
+ Prioritet |
+ Ansvarl. |
+ Gruppe/Level |
+ Opret. |
+ Start arbejde |
+ Start inden |
+ Deadline |
@@ -357,7 +382,13 @@
{% endif %}
#{{ sag.id }}
-
+ |
+ {{ sag.customer_name if sag.customer_name else '-' }}
+ |
+
+ {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
+ |
+
{{ sag.titel }}
{% if sag.beskrivelse %}
{{ sag.beskrivelse }}
@@ -366,29 +397,26 @@
|
{{ sag.template_key or sag.type or 'ticket' }}
|
-
- {{ sag.customer_name if sag.customer_name else '-' }}
+ |
+ {{ sag.priority if sag.priority else 'normal' }}
|
-
- {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
- |
-
+ |
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
-
+ |
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
-
- {{ sag.status }}
- |
-
- {{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
- |
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
- {{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }}
+ {{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
+ |
+
+ {{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
+ |
+
+ {{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
|
{% if has_relations %}
@@ -402,7 +430,13 @@
#{{ related_sag.id }}
|
-
+ |
+ {{ related_sag.customer_name if related_sag.customer_name else '-' }}
+ |
+
+ {{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
+ |
+
{% for rt in all_rel_types %}
{{ rt }}
{% endfor %}
@@ -414,29 +448,26 @@
|
{{ related_sag.template_key or related_sag.type or 'ticket' }}
|
-
- {{ related_sag.customer_name if related_sag.customer_name else '-' }}
+ |
+ {{ related_sag.priority if related_sag.priority else 'normal' }}
|
-
- {{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
- |
-
+ |
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
-
+ |
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
-
- {{ related_sag.status }}
- |
-
- {{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
- |
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
- {{ related_sag.updated_at.strftime('%d/%m-%Y') if related_sag.updated_at else '-' }}
+ {{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
+ |
+
+ {{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
+ |
+
+ {{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
|
{% endif %}
diff --git a/app/modules/webshop/backend/router.py b/app/modules/webshop/backend/router.py
index c3b7d22..032e1bc 100644
--- a/app/modules/webshop/backend/router.py
+++ b/app/modules/webshop/backend/router.py
@@ -12,15 +12,31 @@ import os
import shutil
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
+from app.core.config import settings
logger = logging.getLogger(__name__)
# APIRouter instance (module_loader kigger efter denne)
router = APIRouter()
-# Upload directory for logos
-LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
-os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
+# Upload directory for logos (works in both Docker and local development)
+_logo_base_dir = os.path.abspath(settings.UPLOAD_DIR)
+LOGO_UPLOAD_DIR = os.path.join(_logo_base_dir, "webshop_logos")
+try:
+ os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
+except OSError as exc:
+ if _logo_base_dir.startswith('/app/'):
+ _fallback_base = os.path.abspath('uploads')
+ LOGO_UPLOAD_DIR = os.path.join(_fallback_base, "webshop_logos")
+ os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
+ logger.warning(
+ "⚠️ Webshop logo dir %s not writable (%s). Using fallback %s",
+ _logo_base_dir,
+ exc,
+ LOGO_UPLOAD_DIR,
+ )
+ else:
+ raise
# ============================================================================
diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py
index d393413..4d99884 100644
--- a/app/settings/backend/router.py
+++ b/app/settings/backend/router.py
@@ -10,6 +10,7 @@ from app.core.config import settings
import httpx
import time
import logging
+import json
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -75,7 +76,7 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,))
- if not result and key in {"case_types", "case_type_module_defaults"}:
+ if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}:
seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
@@ -108,6 +109,25 @@ async def get_setting(key: str):
)
)
+ if key == "case_statuses":
+ execute_query(
+ seed_query,
+ (
+ "case_statuses",
+ json.dumps([
+ {"value": "åben", "is_closed": False},
+ {"value": "under behandling", "is_closed": False},
+ {"value": "afventer", "is_closed": False},
+ {"value": "løst", "is_closed": True},
+ {"value": "lukket", "is_closed": True},
+ ], ensure_ascii=False),
+ "system",
+ "Sagsstatus værdier og lukkede markeringer",
+ "json",
+ True,
+ )
+ )
+
result = execute_query(query, (key,))
if not result:
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index e65ef7e..bc841f7 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -1143,6 +1143,33 @@ async def scan_document(file_path: str):
+
+
+
+
Sagsstatus
+
Styr hvilke status-værdier der kan vælges, og marker hvilke der er lukkede.
+
+
+
+
+
+
+
+
+
+
+ | Status |
+ Lukket værdi |
+ Handling |
+
+
+
+ | Indlæser... |
+
+
+
+
+
diff --git a/app/tags/backend/models.py b/app/tags/backend/models.py
index 031c92b..435ded5 100644
--- a/app/tags/backend/models.py
+++ b/app/tags/backend/models.py
@@ -6,7 +6,7 @@ from typing import Optional, List, Literal
from datetime import datetime
# Tag types
-TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
+TagType = Literal['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type']
TagGroupBehavior = Literal['multi', 'single', 'toggle']
@@ -37,6 +37,7 @@ class TagBase(BaseModel):
icon: Optional[str] = None
is_active: bool = True
tag_group_id: Optional[int] = None
+ catch_words: Optional[List[str]] = None
class TagCreate(TagBase):
"""Tag creation model"""
@@ -59,6 +60,7 @@ class TagUpdate(BaseModel):
icon: Optional[str] = None
is_active: Optional[bool] = None
tag_group_id: Optional[int] = None
+ catch_words: Optional[List[str]] = None
class EntityTagBase(BaseModel):
diff --git a/app/tags/backend/router.py b/app/tags/backend/router.py
index 5af8c5b..4aedf58 100644
--- a/app/tags/backend/router.py
+++ b/app/tags/backend/router.py
@@ -3,6 +3,7 @@ Tag system API endpoints
"""
from fastapi import APIRouter, HTTPException
from typing import List, Optional
+import json
from app.tags.backend.models import (
Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate,
@@ -14,6 +15,49 @@ from app.core.database import execute_query, execute_query_single, execute_updat
router = APIRouter(prefix="/tags")
+
+def _normalize_catch_words(value) -> List[str]:
+ """Normalize catch words from JSON/text/list to a clean lowercase list."""
+ if value is None:
+ return []
+ if isinstance(value, list):
+ words = value
+ elif isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return []
+ if stripped.startswith("["):
+ try:
+ parsed = json.loads(stripped)
+ words = parsed if isinstance(parsed, list) else []
+ except Exception:
+ words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
+ else:
+ words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
+ else:
+ words = []
+
+ cleaned = []
+ seen = set()
+ for word in words:
+ normalized = str(word or "").strip().lower()
+ if len(normalized) < 2:
+ continue
+ if normalized in seen:
+ continue
+ seen.add(normalized)
+ cleaned.append(normalized)
+ return cleaned
+
+
+def _tag_row_to_response(row: dict) -> dict:
+ """Ensure API response always exposes catch_words as a list."""
+ if not row:
+ return row
+ out = dict(row)
+ out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
+ return out
+
# ============= TAG GROUPS =============
@router.get("/groups", response_model=List[TagGroup])
@@ -40,7 +84,13 @@ async def list_tags(
is_active: Optional[bool] = None
):
"""List all tags with optional filtering"""
- query = "SELECT * FROM tags WHERE 1=1"
+ query = """
+ SELECT id, name, type, description, color, icon, is_active, tag_group_id,
+ COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
+ created_at, updated_at
+ FROM tags
+ WHERE 1=1
+ """
params = []
if type:
@@ -54,32 +104,52 @@ async def list_tags(
query += " ORDER BY type, name"
results = execute_query(query, tuple(params) if params else ())
- return results
+ return [_tag_row_to_response(row) for row in (results or [])]
@router.get("/{tag_id}", response_model=Tag)
async def get_tag(tag_id: int):
"""Get single tag by ID"""
result = execute_query_single(
- "SELECT * FROM tags WHERE id = %s",
+ """
+ SELECT id, name, type, description, color, icon, is_active, tag_group_id,
+ COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
+ created_at, updated_at
+ FROM tags
+ WHERE id = %s
+ """,
(tag_id,)
)
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
- return result
+ return _tag_row_to_response(result)
@router.post("", response_model=Tag)
async def create_tag(tag: TagCreate):
"""Create new tag"""
query = """
- INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
- VALUES (%s, %s, %s, %s, %s, %s, %s)
- RETURNING *
+ INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id, catch_words_json)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
+ COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
+ created_at, updated_at
"""
+ catch_words = _normalize_catch_words(tag.catch_words)
result = execute_query_single(
query,
- (tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id)
+ (
+ tag.name,
+ tag.type,
+ tag.description,
+ tag.color,
+ tag.icon,
+ tag.is_active,
+ tag.tag_group_id,
+ json.dumps(catch_words),
+ )
)
- return result
+ if not result:
+ raise HTTPException(status_code=500, detail="Failed to create tag")
+ return _tag_row_to_response(result)
@router.put("/{tag_id}", response_model=Tag)
async def update_tag(tag_id: int, tag: TagUpdate):
@@ -106,6 +176,9 @@ async def update_tag(tag_id: int, tag: TagUpdate):
if tag.tag_group_id is not None:
updates.append("tag_group_id = %s")
params.append(tag.tag_group_id)
+ if tag.catch_words is not None:
+ updates.append("catch_words_json = %s::jsonb")
+ params.append(json.dumps(_normalize_catch_words(tag.catch_words)))
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
@@ -117,13 +190,15 @@ async def update_tag(tag_id: int, tag: TagUpdate):
UPDATE tags
SET {', '.join(updates)}
WHERE id = %s
- RETURNING *
+ RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
+ COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
+ created_at, updated_at
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
- return result
+ return _tag_row_to_response(result)
@router.delete("/{tag_id}")
async def delete_tag(tag_id: int):
@@ -214,20 +289,92 @@ async def remove_tag_from_entity_path(
async def get_entity_tags(entity_type: str, entity_id: int):
"""Get all tags for a specific entity"""
query = """
- SELECT t.*
+ SELECT t.id, t.name, t.type, t.description, t.color, t.icon, t.is_active, t.tag_group_id,
+ COALESCE(t.catch_words_json, '[]'::jsonb) AS catch_words,
+ t.created_at, t.updated_at
FROM tags t
JOIN entity_tags et ON et.tag_id = t.id
WHERE et.entity_type = %s AND et.entity_id = %s
ORDER BY t.type, t.name
"""
results = execute_query(query, (entity_type, entity_id))
- return results
+ return [_tag_row_to_response(row) for row in (results or [])]
+
+
+@router.get("/entity/{entity_type}/{entity_id}/suggestions")
+async def suggest_entity_tags(entity_type: str, entity_id: int):
+ """Suggest tags based on catch words for brand/type tags."""
+ if entity_type != "case":
+ return []
+
+ case_row = execute_query_single(
+ "SELECT id, titel, beskrivelse, type, template_key FROM sag_sager WHERE id = %s",
+ (entity_id,),
+ )
+ if not case_row:
+ raise HTTPException(status_code=404, detail="Entity not found")
+
+ existing_rows = execute_query(
+ "SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s",
+ (entity_type, entity_id),
+ ) or []
+ existing_tag_ids = {int(row.get("tag_id")) for row in existing_rows if row.get("tag_id") is not None}
+
+ candidate_rows = execute_query(
+ """
+ SELECT id, name, type, description, color, icon, is_active, tag_group_id,
+ COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
+ created_at, updated_at
+ FROM tags
+ WHERE is_active = true
+ AND type IN ('brand', 'type')
+ ORDER BY type, name
+ """,
+ (),
+ ) or []
+
+ haystack = " ".join(
+ [
+ str(case_row.get("titel") or ""),
+ str(case_row.get("beskrivelse") or ""),
+ str(case_row.get("type") or ""),
+ str(case_row.get("template_key") or ""),
+ ]
+ ).lower()
+
+ suggestions = []
+ for row in candidate_rows:
+ tag_id = int(row.get("id"))
+ if tag_id in existing_tag_ids:
+ continue
+
+ catch_words = _normalize_catch_words(row.get("catch_words"))
+ if not catch_words:
+ continue
+
+ matched_words = [word for word in catch_words if word in haystack]
+ if not matched_words:
+ continue
+
+ suggestions.append(
+ {
+ "tag": _tag_row_to_response(row),
+ "matched_words": matched_words,
+ "score": len(matched_words),
+ }
+ )
+
+ suggestions.sort(key=lambda item: (-item["score"], item["tag"]["type"], item["tag"]["name"]))
+ return suggestions
@router.get("/search")
async def search_tags(q: str, type: Optional[TagType] = None):
"""Search tags by name (fuzzy search)"""
query = """
- SELECT * FROM tags
+ SELECT id, name, type, description, color, icon, is_active, tag_group_id,
+ COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
+ created_at, updated_at
+ FROM tags
WHERE is_active = true
AND LOWER(name) LIKE LOWER(%s)
"""
@@ -240,7 +387,7 @@ async def search_tags(q: str, type: Optional[TagType] = None):
query += " ORDER BY name LIMIT 20"
results = execute_query(query, tuple(params))
- return results
+ return [_tag_row_to_response(row) for row in (results or [])]
# ============= WORKFLOW MANAGEMENT =============
diff --git a/app/tags/frontend/tags_admin.html b/app/tags/frontend/tags_admin.html
index c2e0e8b..3351ab0 100644
--- a/app/tags/frontend/tags_admin.html
+++ b/app/tags/frontend/tags_admin.html
@@ -14,6 +14,8 @@
--category-color: #0f4c75;
--priority-color: #dc3545;
--billing-color: #2d6a4f;
+ --brand-color: #006d77;
+ --type-color: #5c677d;
}
.tag-badge {
@@ -37,6 +39,8 @@
.tag-type-category { background-color: var(--category-color); color: white; }
.tag-type-priority { background-color: var(--priority-color); color: white; }
.tag-type-billing { background-color: var(--billing-color); color: white; }
+ .tag-type-brand { background-color: var(--brand-color); color: white; }
+ .tag-type-type { background-color: var(--type-color); color: white; }
.tag-list-item {
padding: 1rem;
@@ -53,6 +57,8 @@
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); }
.tag-list-item[data-type="priority"] { border-left-color: var(--priority-color); }
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-color); }
+ .tag-list-item[data-type="brand"] { border-left-color: var(--brand-color); }
+ .tag-list-item[data-type="type"] { border-left-color: var(--type-color); }
.color-preview {
width: 40px;
@@ -106,6 +112,16 @@
Billing
+
+
+ Brand
+
+
+
+
+ Type
+
+
@@ -148,9 +164,17 @@
+
+
+
+
+
+ Brug komma eller ny linje mellem ord. Bruges til auto-forslag på sager.
+
+
@@ -229,7 +253,9 @@
'status': '#ffd700',
'category': '#0f4c75',
'priority': '#dc3545',
- 'billing': '#2d6a4f'
+ 'billing': '#2d6a4f',
+ 'brand': '#006d77',
+ 'type': '#5c677d'
};
if (colorMap[type]) {
document.getElementById('tagColor').value = colorMap[type];
@@ -293,6 +319,7 @@
${!tag.is_active ? 'Inaktiv' : ''}
${tag.description ? `
${tag.description}
` : ''}
+ ${Array.isArray(tag.catch_words) && tag.catch_words.length ? `
Catch words: ${tag.catch_words.join(', ')}
` : ''}