From 92b888b78fa0e663174e58261629c1aad678e164 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 20 Mar 2026 00:24:58 +0100 Subject: [PATCH] Add migrations for seeding tags and enhancing todo steps - Created migration 146 to seed case type tags with various categories and keywords. - Created migration 147 to seed brand and type tags, including a comprehensive list of brands and case types. - Added migration 148 to introduce a new column `is_next` in `sag_todo_steps` for persistent next-task selection. - Implemented a new script `run_migrations.py` to facilitate running SQL migrations against the PostgreSQL database with options for dry runs and error handling. --- app/auth/backend/router.py | 18 +- app/backups/backend/service.py | 22 +- app/core/auth_dependencies.py | 21 +- app/core/auth_service.py | 68 +- app/core/database.py | 32 + app/dashboard/backend/views.py | 22 +- app/models/schemas.py | 2 + app/modules/sag/backend/router.py | 87 +- app/modules/sag/frontend/views.py | 24 +- app/modules/sag/templates/detail.html | 1419 ++++++++++++++++- app/modules/sag/templates/index.html | 127 +- app/modules/webshop/backend/router.py | 22 +- app/settings/backend/router.py | 22 +- app/settings/frontend/settings.html | 27 + app/tags/backend/models.py | 4 +- app/tags/backend/router.py | 177 +- app/tags/frontend/tags_admin.html | 36 +- .../frontend/mockups/tech_v1_overview.html | 154 +- .../frontend/mockups/tech_v2_workboard.html | 30 +- .../frontend/mockups/tech_v3_table_focus.html | 62 +- app/ticket/frontend/views.py | 139 +- main.py | 38 +- migrations/013_email_system.sql | 6 +- migrations/026_ticket_enhancements.sql | 48 +- migrations/031_add_is_travel_column.sql | 18 +- migrations/056_email_import_method.sql | 9 +- migrations/069_conversation_category.sql | 2 +- .../072_add_category_to_conversations.sql | 2 +- migrations/082_sag_comments.sql | 2 +- migrations/115_anydesk_sessions.sql | 2 +- .../144_tags_brand_type_catch_words.sql | 39 + migrations/145_seed_brand_tags_a_z.sql | 51 + migrations/146_seed_type_tags_case_types.sql | 101 ++ .../147_seed_brand_and_type_tags_master.sql | 148 ++ migrations/148_sag_todo_next_flag.sql | 5 + scripts/run_migrations.py | 159 ++ static/js/tag-picker.js | 6 +- 37 files changed, 2844 insertions(+), 307 deletions(-) create mode 100644 migrations/144_tags_brand_type_catch_words.sql create mode 100644 migrations/145_seed_brand_tags_a_z.sql create mode 100644 migrations/146_seed_type_tags_case_types.sql create mode 100644 migrations/147_seed_brand_and_type_tags_master.sql create mode 100644 migrations/148_sag_todo_next_flag.sql create mode 100644 scripts/run_migrations.py 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 @@
-
+
-
+
📍 Lokationer
@@ -1431,7 +2066,7 @@
-
+
@@ -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 @@
-
+
@@ -3559,6 +4410,28 @@
+
+
+
🏷️ TAGS
+ +
+
+
+
Indlaeser tags...
+
+
+
Forslag (brand/type)
+
+
Indlaeser forslag...
+
+
+
+
+ @@ -5640,6 +6513,25 @@ {% endif %} +
+ + +