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.
This commit is contained in:
Christian 2026-03-20 00:24:58 +01:00
parent dcae962481
commit 92b888b78f
37 changed files with 2844 additions and 307 deletions

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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"
@ -26,6 +48,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,)

View File

@ -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)

View File

@ -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
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
raw_alerts = execute_query(bankruptcy_query) or []
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:

View File

@ -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

View File

@ -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",

View File

@ -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(),

File diff suppressed because it is too large Load Diff

View File

@ -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 %}
<div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);">
@ -330,17 +354,18 @@
<table class="sag-table">
<thead>
<tr>
<th style="width: 90px;">ID</th>
<th>Titel & Beskrivelse</th>
<th style="width: 90px;">SagsID</th>
<th style="width: 180px;">Virksom.</th>
<th style="width: 150px;">Kontakt</th>
<th style="width: 300px;">Beskr.</th>
<th style="width: 120px;">Type</th>
<th style="width: 180px;">Kunde</th>
<th style="width: 150px;">Hovedkontakt</th>
<th style="width: 160px;">Ansvarlig</th>
<th style="width: 160px;">Gruppe</th>
<th style="width: 100px;">Status</th>
<th style="width: 120px;">Udsat start</th>
<th style="width: 120px;">Oprettet</th>
<th style="width: 120px;">Opdateret</th>
<th style="width: 110px;">Prioritet</th>
<th style="width: 160px;">Ansvarl.</th>
<th style="width: 170px;">Gruppe/Level</th>
<th style="width: 120px;">Opret.</th>
<th style="width: 120px;">Start arbejde</th>
<th style="width: 140px;">Start inden</th>
<th style="width: 120px;">Deadline</th>
</tr>
</thead>
<tbody id="sagTableBody">
@ -357,7 +382,13 @@
{% endif %}
<span class="sag-id">#{{ sag.id }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
@ -366,29 +397,26 @@
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ sag.priority if sag.priority else 'normal' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }}
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
</td>
</tr>
{% if has_relations %}
@ -402,7 +430,13 @@
<td>
<span class="sag-id">#{{ related_sag.id }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span>
{% endfor %}
@ -414,29 +448,26 @@
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ related_sag.priority if related_sag.priority else 'normal' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ 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 '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
</td>
</tr>
{% endif %}

View File

@ -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
# ============================================================================

View File

@ -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:

View File

@ -1143,6 +1143,33 @@ async def scan_document(file_path: str):
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Sagsstatus</h5>
<p class="text-muted mb-0">Styr hvilke status-værdier der kan vælges, og marker hvilke der er lukkede.</p>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="caseStatusInput" placeholder="F.eks. afventer kunde" style="max-width: 260px;">
<button class="btn btn-primary" onclick="addCaseStatus()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Status</th>
<th class="text-center" style="width: 150px;">Lukket værdi</th>
<th class="text-end" style="width: 100px;">Handling</th>
</tr>
</thead>
<tbody id="caseStatusesTableBody">
<tr><td colspan="3" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>

View File

@ -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):

View File

@ -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 =============

View File

@ -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 @@
<span class="tag-badge tag-type-billing">Billing</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="brand">
<span class="tag-badge tag-type-brand">Brand</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="type">
<span class="tag-badge tag-type-type">Type</span>
</a>
</li>
</ul>
<!-- Tags List -->
@ -148,9 +164,17 @@
<option value="category">Category - Emne/område</option>
<option value="priority">Priority - Hastighed</option>
<option value="billing">Billing - Økonomi</option>
<option value="brand">Brand - Leverandør/produktbrand</option>
<option value="type">Type - Sagstype/arbejdstype</option>
</select>
</div>
<div class="mb-3">
<label for="tagCatchWords" class="form-label">Catch words</label>
<textarea class="form-control" id="tagCatchWords" rows="3" placeholder="fx: office 365, outlook, smtp"></textarea>
<small class="text-muted">Brug komma eller ny linje mellem ord. Bruges til auto-forslag på sager.</small>
</div>
<div class="mb-3">
<label for="tagDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
@ -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 ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
</div>
${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''}
${Array.isArray(tag.catch_words) && tag.catch_words.length ? `<p class="mb-0 mt-1"><small class="text-muted">Catch words: ${tag.catch_words.join(', ')}</small></p>` : ''}
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
@ -315,7 +342,11 @@
description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked
is_active: document.getElementById('tagActive').checked,
catch_words: document.getElementById('tagCatchWords').value
.split(/[\n,]+/)
.map(v => v.trim().toLowerCase())
.filter(v => v.length > 1)
};
try {
@ -352,6 +383,7 @@
document.getElementById('tagColorHex').value = tag.color;
document.getElementById('tagIcon').value = tag.icon || '';
document.getElementById('tagActive').checked = tag.is_active;
document.getElementById('tagCatchWords').value = Array.isArray(tag.catch_words) ? tag.catch_words.join(', ') : '';
document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
new bootstrap.Modal(document.getElementById('createTagModal')).show();

View File

@ -2,6 +2,21 @@
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
{% block extra_css %}
<style>
#caseTable thead th {
white-space: nowrap;
font-size: 0.78rem;
letter-spacing: 0.02em;
}
#caseTable tbody td {
font-size: 0.84rem;
vertical-align: top;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
@ -65,15 +80,22 @@
<table class="table table-sm table-hover mb-0" id="caseTable">
<thead class="table-light" id="tableHead">
<tr>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Dato</th>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>Prioritet</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
</tbody>
</table>
</div>
@ -167,8 +189,16 @@ const allData = {
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
@ -179,7 +209,16 @@ const allData = {
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
@ -191,9 +230,16 @@ const allData = {
item_type: {{ item.item_type | tojson | safe }},
item_id: {{ item.item_id }},
title: {{ item.title | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}
}{% if not loop.last %},{% endif %}
@ -205,7 +251,16 @@ const allData = {
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
group_name: {{ item.group_name | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
@ -225,6 +280,32 @@ function formatShortDate(dateStr) {
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function renderCaseTableRow(item, idField = 'id', typeField = 'case') {
const itemId = item[idField];
const openType = typeField === 'item_type' ? item.item_type : 'case';
const description = item.beskrivelse || item.titel || item.title || '-';
const typeValue = item.case_type || item.item_type || '-';
const groupLevel = item.assigned_group_name || item.group_name || '-';
const priorityValue = item.priority || 'normal';
return `
<tr onclick="showCaseDetails(${itemId}, '${openType}')" style="cursor:pointer;">
<td>#${itemId}</td>
<td>${item.customer_name || '-'}</td>
<td>${item.kontakt_navn || '-'}</td>
<td>${description}</td>
<td>${typeValue}</td>
<td>${priorityValue}</td>
<td>${item.ansvarlig_navn || '-'}</td>
<td>${groupLevel}</td>
<td>${formatShortDate(item.created_at)}</td>
<td>${formatShortDate(item.start_date)}</td>
<td>${formatShortDate(item.deferred_until)}</td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`;
}
function toggleSection(filterName) {
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
const listTitle = document.getElementById('listTitle');
@ -242,7 +323,7 @@ function toggleSection(filterName) {
if (currentFilter === filterName) {
currentFilter = null;
listTitle.textContent = 'Alle sager';
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
tableBody.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
return;
}
@ -266,70 +347,43 @@ function filterAndPopulateTable(filterName) {
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
const data = allData.newCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-secondary">${item.status || 'Ny'}</span></td>
<td>${formatDate(item.created_at)}</td>
</tr>
`).join('');
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
}
} else if (filterName === 'myCases') {
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
const data = allData.myCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
}
} else if (filterName === 'todayTasks') {
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
const data = allData.todayTasks || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
} else {
bodyHTML = data.map(item => {
const badge = item.item_type === 'case'
? '<span class="badge bg-primary">Sag</span>'
: '<span class="badge bg-info">Ticket</span>';
return `
<tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
<td>#${item.item_id}</td>
<td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
<td>${item.customer_name || '-'}</td>
<td>${badge}</td>
<td>${formatDate(item.created_at)}</td>
</tr>
`;
const normalized = {
...item,
id: item.item_id,
titel: item.title,
beskrivelse: item.task_reason || item.beskrivelse,
deadline: item.deadline || item.due_at,
case_type: item.case_type || item.item_type
};
return renderCaseTableRow(normalized, 'id', 'item_type');
}).join('');
}
} else if (filterName === 'groupCases') {
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
const data = allData.groupCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}<br><span class="badge bg-secondary">${item.group_name || '-'}</span></td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
}
}

View File

@ -86,14 +86,38 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
</tr>
</thead>
<tbody>
{% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<td>#{{ item.id }}</td><td>{{ item.titel }}</td><td>{{ item.customer_name }}</td><td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
<td>#{{ item.id }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
<td>{{ item.case_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
<tr><td colspan="11" class="text-center text-muted py-3">Ingen nye sager</td></tr>
{% endfor %}
</tbody>
</table>

View File

@ -32,59 +32,71 @@
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet/Reason</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
<th>Handling</th>
</tr>
</thead>
<tbody>
{% for item in urgent_overdue %}
<tr>
<td><span class="badge bg-danger">Haste</span></td>
<td>#{{ item.item_id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.attention_reason }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.title or '-' }}</td>
<td>{{ item.case_type or item.item_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-danger">Åbn</a></td>
</tr>
{% endfor %}
{% for item in today_tasks %}
<tr>
<td><span class="badge bg-primary">I dag</span></td>
<td>#{{ item.item_id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.task_reason }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.title or item.task_reason or '-' }}</td>
<td>{{ item.case_type or item.item_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr>
{% endfor %}
{% for item in my_cases %}
<tr>
<td><span class="badge bg-secondary">Min sag</span></td>
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>-</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
<td>{{ item.case_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary">Åbn</a></td>
</tr>
{% endfor %}
{% if not urgent_overdue and not today_tasks and not my_cases %}
<tr>
<td colspan="8" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
<td colspan="11" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
</tr>
{% endif %}
</tbody>

View File

@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
from typing import Optional, Dict, Any
from datetime import date
from app.core.database import execute_query, execute_update, execute_query_single
from app.core.database import execute_query, execute_update, execute_query_single, table_has_column
logger = logging.getLogger(__name__)
@ -18,6 +18,20 @@ router = APIRouter()
templates = Jinja2Templates(directory="app")
def _case_start_date_sql(alias: str = "s") -> str:
"""Select start_date only when the live schema actually has it."""
if table_has_column("sag_sager", "start_date"):
return f"{alias}.start_date"
return "NULL::date AS start_date"
def _case_type_sql(alias: str = "s") -> str:
"""Select case type across old/new sag schemas."""
if table_has_column("sag_sager", "type"):
return f"COALESCE({alias}.template_key, {alias}.type, 'ticket') AS case_type"
return f"COALESCE({alias}.template_key, 'ticket') AS case_type"
@router.get("/", include_in_schema=False)
async def ticket_root_redirect():
return RedirectResponse(url="/sag", status_code=302)
@ -362,6 +376,8 @@ async def new_ticket_page(request: Request):
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""Collect live data slices for technician-focused dashboard variants."""
case_start_date_sql = _case_start_date_sql()
case_type_sql = _case_type_sql()
user_query = """
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
FROM users
@ -371,16 +387,34 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
user_result = execute_query(user_query, (technician_user_id,))
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
new_cases_query = """
new_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
{case_type_sql},
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.status = 'åben'
ORDER BY s.created_at DESC
@ -388,16 +422,34 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
new_cases = execute_query(new_cases_query)
my_cases_query = """
my_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
{case_type_sql},
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
@ -406,19 +458,36 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
my_cases = execute_query(my_cases_query, (technician_user_id,))
today_tasks_query = """
today_tasks_query = f"""
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.beskrivelse,
s.status,
s.deadline AS due_at,
s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
NULL::text AS priority,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
COALESCE(s.priority::text, 'normal') AS priority,
'Sag deadline i dag' AS task_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
@ -430,14 +499,22 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
NULL::text AS beskrivelse,
t.status,
NULL::date AS due_at,
t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority,
'Ticket oprettet i dag' AS task_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.assigned_to_user_id = %s
AND t.status IN ('open', 'in_progress', 'pending_customer')
AND DATE(t.created_at) = CURRENT_DATE
@ -447,19 +524,36 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
urgent_overdue_query = """
urgent_overdue_query = f"""
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.beskrivelse,
s.status,
s.deadline AS due_at,
s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
NULL::text AS priority,
'Over deadline' AS attention_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.status <> 'lukket'
AND s.deadline IS NOT NULL
@ -471,10 +565,17 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
NULL::text AS beskrivelse,
t.status,
NULL::date AS due_at,
t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority,
CASE
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
@ -482,6 +583,7 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
END AS attention_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
AND COALESCE(t.priority, '') IN ('urgent', 'high')
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
@ -542,19 +644,36 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
# Get group cases (cases assigned to user's groups)
group_cases = []
if user_group_ids:
group_cases_query = """
group_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
{case_type_sql},
s.assigned_group_id,
g.name AS group_name,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.assigned_group_id = ANY(%s)
AND s.status <> 'lukket'

38
main.py
View File

@ -16,6 +16,29 @@ from app.core.database import init_db
from app.core.auth_service import AuthService
from app.core.database import execute_query_single
_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
def get_version():
"""Read version from VERSION file"""
try:
@ -265,11 +288,16 @@ async def auth_middleware(request: Request, call_next):
content={"detail": "Invalid token"}
)
user_id = int(payload.get("sub"))
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
if _users_column_exists("is_2fa_enabled"):
user = execute_query_single(
"SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,),
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
else:
# Older schemas without 2FA columns should not block authenticated requests.
is_2fa_enabled = False
if not is_2fa_enabled:
allowed_2fa_paths = (

View File

@ -37,7 +37,7 @@ CREATE TABLE email_rules (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INTEGER,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL
);
-- Email Messages Table (main storage)
@ -183,7 +183,7 @@ SELECT
COUNT(ea.id) as attachment_count_actual,
er.name as rule_name,
v.name as supplier_name,
tc.customer_name,
tc.name as customer_name,
tcase.title as case_title
FROM email_messages em
LEFT JOIN email_attachments ea ON em.id = ea.email_id
@ -193,7 +193,7 @@ LEFT JOIN tmodule_customers tc ON em.customer_id = tc.id
LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id
WHERE em.deleted_at IS NULL
AND em.status IN ('new', 'error')
GROUP BY em.id, er.name, v.name, tc.customer_name, tcase.title
GROUP BY em.id, er.name, v.name, tc.name, tcase.title
ORDER BY em.received_date DESC;
-- View for recent email activity

View File

@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS tticket_relations (
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
);
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_type ON tticket_relations(relation_type);
-- View for at finde alle relationer for en ticket (begge retninger)
CREATE OR REPLACE VIEW tticket_all_relations AS
@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS tticket_calendar_events (
completed_at TIMESTAMP
);
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_status ON tticket_calendar_events(status);
-- ============================================================================
-- TEMPLATES (svarskabeloner, guides, standardbreve)
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
usage_count INTEGER DEFAULT 0
);
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active);
CREATE INDEX IF NOT EXISTS idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX IF NOT EXISTS idx_tticket_templates_active ON tticket_templates(is_active);
-- ============================================================================
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
@ -143,8 +143,8 @@ CREATE TABLE IF NOT EXISTS tticket_template_usage (
was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
);
CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
-- ============================================================================
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
@ -186,10 +186,10 @@ CREATE TABLE IF NOT EXISTS tticket_ai_suggestions (
expires_at TIMESTAMP -- Forslag udløber efter X dage
);
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
CREATE INDEX idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
CREATE INDEX idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
-- ============================================================================
-- EMAIL METADATA (udvidet til contact identification)
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
updated_at TIMESTAMP
);
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
CREATE INDEX idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email);
CREATE INDEX IF NOT EXISTS idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX IF NOT EXISTS idx_tticket_email_from ON tticket_email_metadata(from_email);
-- ============================================================================
-- Tilføj manglende kolonner til existing tticket_tickets
@ -265,9 +265,15 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
metadata JSONB -- Additional context
);
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
ALTER TABLE tticket_audit_log
ADD COLUMN IF NOT EXISTS field_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS reason TEXT,
ADD COLUMN IF NOT EXISTS metadata JSONB;
CREATE INDEX IF NOT EXISTS idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
-- ============================================================================
-- TRIGGERS for audit logging

View File

@ -24,7 +24,17 @@ ADD COLUMN IF NOT EXISTS time_date DATE;
ALTER TABLE tmodule_order_lines
ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false;
-- Log migration
INSERT INTO migration_log (migration_name, applied_at)
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;
-- Log migration when the legacy tracking table exists
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'migration_log'
) THEN
INSERT INTO migration_log (migration_name, applied_at)
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;
END IF;
END $$;

View File

@ -4,13 +4,13 @@
-- Add import_method column
ALTER TABLE email_messages
ADD COLUMN import_method VARCHAR(50) DEFAULT 'imap';
ADD COLUMN IF NOT EXISTS import_method VARCHAR(50) DEFAULT 'imap';
-- Add comment
COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload';
-- Create index for filtering by import method
CREATE INDEX idx_email_messages_import_method ON email_messages(import_method);
CREATE INDEX IF NOT EXISTS idx_email_messages_import_method ON email_messages(import_method);
-- Update existing records to reflect their actual source
-- (all existing emails were fetched via IMAP or Graph API)
@ -19,6 +19,9 @@ SET import_method = 'imap'
WHERE import_method IS NULL;
-- Add constraint to ensure valid values
ALTER TABLE email_messages
DROP CONSTRAINT IF EXISTS chk_email_import_method;
ALTER TABLE email_messages
ADD CONSTRAINT chk_email_import_method
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));

View File

@ -1,5 +1,5 @@
-- 069_conversation_category.sql
-- Add category column for conversation classification
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';

View File

@ -1,4 +1,4 @@
-- 072_add_category_to_conversations.sql
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';

View File

@ -11,4 +11,4 @@ CREATE TABLE IF NOT EXISTS sag_kommentarer (
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
);
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_kommentarer_sag_id ON sag_kommentarer(sag_id);

View File

@ -51,7 +51,7 @@ SELECT
s.customer_id,
cust.name as customer_name,
s.sag_id,
sag.title as sag_title,
sag.titel as sag_title,
s.session_link,
s.started_at,
s.ended_at,

View File

@ -0,0 +1,39 @@
-- Migration 144: Extend tags with brand/type classes and catch words
-- Add catch words storage for tag suggestion matching
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS catch_words_json JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Extend allowed tag types to include brand and type
DO $$
DECLARE
constraint_name text;
BEGIN
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_type_check;
FOR constraint_name IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
WHERE rel.relname = 'tags'
AND con.contype = 'c'
AND pg_get_constraintdef(con.oid) ILIKE '%type IN (%'
LOOP
EXECUTE format('ALTER TABLE tags DROP CONSTRAINT %I', constraint_name);
END LOOP;
END $$;
ALTER TABLE tags
ADD CONSTRAINT tags_type_check
CHECK (type IN ('workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'));
-- Seed a couple of starter tags for the new classes
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('Microsoft', 'brand', 'Brand tag for Microsoft related cases', '#006d77', 'bi-microsoft', true, '["microsoft","ms 365","office 365","azure"]'::jsonb),
('Adobe', 'brand', 'Brand tag for Adobe related cases', '#006d77', 'bi-box', true, '["adobe","acrobat","creative cloud"]'::jsonb),
('Printer', 'type', 'Type tag for printer related work', '#5c677d', 'bi-printer', true, '["printer","toner","print","scanner"]'::jsonb),
('Email', 'type', 'Type tag for mail related work', '#5c677d', 'bi-envelope', true, '["mail","email","outlook","smtp","imap"]'::jsonb)
ON CONFLICT (name, type) DO NOTHING;
COMMENT ON COLUMN tags.catch_words_json IS 'JSON array of catch words used for automated tag suggestions';

View File

@ -0,0 +1,51 @@
-- Migration 145: Seed brand tags (A-Z starter set)
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
-- Keep for historical traceability; do not run together with 147.
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,101 @@
-- Migration 146: Seed type tags (case type starter set)
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
-- Keep for historical traceability; do not run together with 147.
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,148 @@
-- Migration 147: Master seed for brand + type tags
-- Depends on migration 144 (brand/type + catch_words_json)
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,5 @@
-- Add persistent next-task selection for case todo steps
ALTER TABLE sag_todo_steps
ADD COLUMN IF NOT EXISTS is_next BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_is_next ON sag_todo_steps (sag_id, is_next);

159
scripts/run_migrations.py Normal file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
import argparse
import logging
import os
import re
import sys
from pathlib import Path
import psycopg2
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.config import settings
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
NUMBERED_SQL_RE = re.compile(r"^\d+.*\.sql$")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run BMC Hub SQL migrations against the configured PostgreSQL database."
)
parser.add_argument(
"files",
nargs="*",
help="Specific SQL files to run, relative to repo root (for example migrations/145_sag_start_date.sql).",
)
parser.add_argument(
"--all",
action="store_true",
help="Run all numbered SQL files from ./migrations in numeric order. Default when no files are provided.",
)
parser.add_argument(
"--module",
action="append",
default=[],
help="Also run numbered SQL files from a module migration directory, relative to repo root.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the files that would run without executing them.",
)
parser.add_argument(
"--stop-on-error",
action="store_true",
help="Stop immediately on the first migration failure.",
)
return parser.parse_args()
def patch_database_url_for_local_dev() -> None:
if "@postgres" in settings.DATABASE_URL:
logger.info("Patching DATABASE_URL for local run")
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
def collect_numbered_sql(directory: Path) -> list[Path]:
files = [p for p in directory.glob("*.sql") if NUMBERED_SQL_RE.match(p.name)]
files.sort(key=lambda p: (int(re.match(r"^(\d+)", p.name).group(1)), p.name))
return files
def resolve_explicit_files(file_args: list[str]) -> list[Path]:
resolved = []
for raw in file_args:
path = (ROOT / raw).resolve()
if not path.exists():
raise FileNotFoundError(f"Migration file not found: {raw}")
resolved.append(path)
return resolved
def build_file_list(args: argparse.Namespace) -> list[Path]:
files: list[Path] = []
if args.files:
files.extend(resolve_explicit_files(args.files))
else:
files.extend(collect_numbered_sql(ROOT / "migrations"))
for module_dir in args.module:
path = (ROOT / module_dir).resolve()
if not path.exists() or not path.is_dir():
raise FileNotFoundError(f"Module migration directory not found: {module_dir}")
files.extend(collect_numbered_sql(path))
# Preserve order but remove duplicates.
unique_files: list[Path] = []
seen: set[Path] = set()
for path in files:
if path not in seen:
unique_files.append(path)
seen.add(path)
return unique_files
def run_files(files: list[Path], dry_run: bool, stop_on_error: bool) -> int:
if not files:
logger.info("No migration files selected.")
return 0
if dry_run:
for path in files:
logger.info("DRY %s", path.relative_to(ROOT))
return 0
conn = psycopg2.connect(settings.DATABASE_URL)
conn.autocommit = False
cur = conn.cursor()
failures: list[tuple[Path, str]] = []
try:
for path in files:
rel = path.relative_to(ROOT)
sql = path.read_text(encoding="utf-8")
try:
cur.execute(sql)
conn.commit()
logger.info("OK %s", rel)
except Exception as exc:
conn.rollback()
message = str(exc).strip().splitlines()[0]
failures.append((path, message))
logger.error("FAIL %s: %s", rel, message)
if stop_on_error:
break
finally:
cur.close()
conn.close()
if failures:
logger.error("")
logger.error("Failed migrations:")
for path, message in failures:
logger.error("- %s: %s", path.relative_to(ROOT), message)
return 1
logger.info("")
logger.info("All selected migrations completed successfully.")
return 0
def main() -> int:
args = parse_args()
patch_database_url_for_local_dev()
files = build_file_list(args)
return run_files(files, dry_run=args.dry_run, stop_on_error=args.stop_on_error)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -212,10 +212,12 @@ class TagPicker {
'status': '📊 Status - Tilstand',
'category': '📁 Kategori - Emne',
'priority': '🔥 Prioritet - Hastighed',
'billing': '💰 Fakturering - Økonomi'
'billing': '💰 Fakturering - Økonomi',
'brand': '🏷️ Brand - Leverandør/produkt',
'type': '🧩 Type - Sagstype'
};
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing'];
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'];
let html = '';
typeOrder.forEach(type => {