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:
parent
dcae962481
commit
92b888b78f
@ -74,6 +74,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
|
|||||||
|
|
||||||
requires_2fa_setup = (
|
requires_2fa_setup = (
|
||||||
not user.get("is_shadow_admin", False)
|
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)
|
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",
|
detail="Shadow admin cannot configure 2FA",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = AuthService.setup_user_2fa(
|
try:
|
||||||
user_id=current_user["id"],
|
result = AuthService.setup_user_2fa(
|
||||||
username=current_user["username"]
|
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
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -25,8 +25,26 @@ class BackupService:
|
|||||||
"""Service for managing backup operations"""
|
"""Service for managing backup operations"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
|
configured_backup_dir = Path(settings.BACKUP_STORAGE_PATH)
|
||||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
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
|
# Subdirectories for different backup types
|
||||||
self.db_dir = self.backup_dir / "database"
|
self.db_dir = self.backup_dir / "database"
|
||||||
|
|||||||
@ -15,6 +15,21 @@ logger = logging.getLogger(__name__)
|
|||||||
security = HTTPBearer(auto_error=False)
|
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(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
@ -70,9 +85,11 @@ async def get_current_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get additional user details from database
|
# 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(
|
user_details = execute_query_single(
|
||||||
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
|
f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
|
||||||
(user_id,))
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
|
|||||||
@ -15,6 +15,28 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# JWT Settings
|
||||||
SECRET_KEY = settings.JWT_SECRET_KEY
|
SECRET_KEY = settings.JWT_SECRET_KEY
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
@ -26,6 +48,11 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"],
|
|||||||
class AuthService:
|
class AuthService:
|
||||||
"""Service for authentication and authorization"""
|
"""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
|
@staticmethod
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""
|
"""
|
||||||
@ -89,6 +116,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def setup_user_2fa(user_id: int, username: str) -> Dict:
|
def setup_user_2fa(user_id: int, username: str) -> Dict:
|
||||||
"""Create and store a new TOTP secret (not enabled until verified)"""
|
"""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()
|
secret = AuthService.generate_2fa_secret()
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
"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
|
@staticmethod
|
||||||
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
|
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||||
"""Enable 2FA after verifying TOTP code"""
|
"""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(
|
user = execute_query_single(
|
||||||
"SELECT totp_secret FROM users WHERE user_id = %s",
|
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
@ -123,6 +156,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
|
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||||
"""Disable 2FA after verifying TOTP code"""
|
"""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(
|
user = execute_query_single(
|
||||||
"SELECT totp_secret FROM users WHERE user_id = %s",
|
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
@ -151,10 +187,11 @@ class AuthService:
|
|||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
execute_update(
|
if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"):
|
||||||
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
execute_update(
|
||||||
(user_id,)
|
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
)
|
(user_id,)
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -256,13 +293,18 @@ class AuthService:
|
|||||||
request_username = (username or "").strip().lower()
|
request_username = (username or "").strip().lower()
|
||||||
|
|
||||||
# Get user
|
# 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(
|
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_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
|
FROM users
|
||||||
WHERE username = %s OR email = %s""",
|
WHERE username = %s OR email = %s""",
|
||||||
(username, username))
|
(username, username),
|
||||||
|
)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
# Shadow Admin fallback (only when no regular user matches)
|
# Shadow Admin fallback (only when no regular user matches)
|
||||||
@ -367,10 +409,11 @@ class AuthService:
|
|||||||
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
||||||
return None, "Invalid 2FA code"
|
return None, "Invalid 2FA code"
|
||||||
|
|
||||||
execute_update(
|
if _users_column_exists("last_2fa_at"):
|
||||||
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
execute_update(
|
||||||
(user['user_id'],)
|
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
)
|
(user['user_id'],)
|
||||||
|
)
|
||||||
|
|
||||||
# Success! Reset failed attempts and update last login
|
# Success! Reset failed attempts and update last login
|
||||||
execute_update(
|
execute_update(
|
||||||
@ -416,6 +459,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def is_user_2fa_enabled(user_id: int) -> bool:
|
def is_user_2fa_enabled(user_id: int) -> bool:
|
||||||
"""Check if user has 2FA enabled"""
|
"""Check if user has 2FA enabled"""
|
||||||
|
if not _users_column_exists("is_2fa_enabled"):
|
||||||
|
return False
|
||||||
|
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ PostgreSQL connection and helpers using psycopg2
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from psycopg2.pool import SimpleConnectionPool
|
from psycopg2.pool import SimpleConnectionPool
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
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)"""
|
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
return result[0] if result and len(result) > 0 else None
|
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)
|
||||||
|
|||||||
@ -125,10 +125,24 @@ async def dashboard(request: Request):
|
|||||||
|
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
|
||||||
result = execute_query_single(unknown_query)
|
try:
|
||||||
unknown_count = result['count'] if result else 0
|
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 = []
|
bankruptcy_alerts = []
|
||||||
|
|
||||||
for alert in raw_alerts:
|
for alert in raw_alerts:
|
||||||
|
|||||||
@ -280,6 +280,7 @@ class TodoStepCreate(TodoStepBase):
|
|||||||
class TodoStepUpdate(BaseModel):
|
class TodoStepUpdate(BaseModel):
|
||||||
"""Schema for updating a todo step"""
|
"""Schema for updating a todo step"""
|
||||||
is_done: Optional[bool] = None
|
is_done: Optional[bool] = None
|
||||||
|
is_next: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class TodoStep(TodoStepBase):
|
class TodoStep(TodoStepBase):
|
||||||
@ -287,6 +288,7 @@ class TodoStep(TodoStepBase):
|
|||||||
id: int
|
id: int
|
||||||
sag_id: int
|
sag_id: int
|
||||||
is_done: bool
|
is_done: bool
|
||||||
|
is_next: bool = False
|
||||||
created_by_user_id: Optional[int] = None
|
created_by_user_id: Optional[int] = None
|
||||||
created_by_name: Optional[str] = None
|
created_by_name: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@ -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_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
|
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
|
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 []
|
return execute_query(query, (sag_id,)) or []
|
||||||
except Exception as e:
|
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)
|
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
|
||||||
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
|
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
|
||||||
try:
|
try:
|
||||||
if data.is_done is None:
|
if data.is_done is None and data.is_next is None:
|
||||||
raise HTTPException(status_code=400, detail="is_done is required")
|
raise HTTPException(status_code=400, detail="Provide is_done or is_next")
|
||||||
|
|
||||||
user_id = _get_user_id_from_request(request)
|
step_row = execute_query_single(
|
||||||
if data.is_done:
|
"SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL",
|
||||||
update_query = """
|
(step_id,)
|
||||||
UPDATE sag_todo_steps
|
)
|
||||||
SET is_done = TRUE,
|
if not step_row:
|
||||||
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:
|
|
||||||
raise HTTPException(status_code=404, detail="Todo step not found")
|
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(
|
return execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -552,8 +581,12 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
updates["status"] = _normalize_case_status(updates.get("status"))
|
updates["status"] = _normalize_case_status(updates.get("status"))
|
||||||
if "deadline" in updates:
|
if "deadline" in updates:
|
||||||
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
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:
|
if "deferred_until" in updates:
|
||||||
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
|
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:
|
if "ansvarlig_bruger_id" in updates:
|
||||||
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
||||||
_validate_user_id(updates["ansvarlig_bruger_id"])
|
_validate_user_id(updates["ansvarlig_bruger_id"])
|
||||||
@ -569,6 +602,8 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
"status",
|
"status",
|
||||||
"ansvarlig_bruger_id",
|
"ansvarlig_bruger_id",
|
||||||
"assigned_group_id",
|
"assigned_group_id",
|
||||||
|
"priority",
|
||||||
|
"start_date",
|
||||||
"deadline",
|
"deadline",
|
||||||
"deferred_until",
|
"deferred_until",
|
||||||
"deferred_until_case_id",
|
"deferred_until_case_id",
|
||||||
|
|||||||
@ -163,6 +163,28 @@ async def sager_liste(
|
|||||||
|
|
||||||
# Fetch all distinct statuses and tags for filters
|
# 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", ())
|
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", ())
|
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(
|
toggle_include_deferred_url = str(
|
||||||
@ -475,7 +497,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"nextcloud_instance": nextcloud_instance,
|
"nextcloud_instance": nextcloud_instance,
|
||||||
"related_case_options": related_case_options,
|
"related_case_options": related_case_options,
|
||||||
"pipeline_stages": pipeline_stages,
|
"pipeline_stages": pipeline_stages,
|
||||||
"status_options": [s["status"] for s in statuses],
|
"status_options": status_options,
|
||||||
"is_deadline_overdue": is_deadline_overdue,
|
"is_deadline_overdue": is_deadline_overdue,
|
||||||
"assignment_users": _fetch_assignment_users(),
|
"assignment_users": _fetch_assignment_users(),
|
||||||
"assignment_groups": _fetch_assignment_groups(),
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -17,12 +17,14 @@
|
|||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sag-table {
|
.sag-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 1550px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,12 +34,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sag-table thead th {
|
.sag-table thead th {
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.6rem 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.3px;
|
||||||
border: none;
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sag-table tbody tr {
|
.sag-table tbody tr {
|
||||||
@ -51,9 +54,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sag-table tbody td {
|
.sag-table tbody td {
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.5rem 0.75rem;
|
||||||
vertical-align: middle;
|
vertical-align: top;
|
||||||
font-size: 0.9rem;
|
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 {
|
.sag-id {
|
||||||
@ -246,7 +270,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="margin: 0; color: var(--accent);">
|
<h1 style="margin: 0; color: var(--accent);">
|
||||||
@ -330,17 +354,18 @@
|
|||||||
<table class="sag-table">
|
<table class="sag-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 90px;">ID</th>
|
<th style="width: 90px;">SagsID</th>
|
||||||
<th>Titel & Beskrivelse</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: 120px;">Type</th>
|
||||||
<th style="width: 180px;">Kunde</th>
|
<th style="width: 110px;">Prioritet</th>
|
||||||
<th style="width: 150px;">Hovedkontakt</th>
|
<th style="width: 160px;">Ansvarl.</th>
|
||||||
<th style="width: 160px;">Ansvarlig</th>
|
<th style="width: 170px;">Gruppe/Level</th>
|
||||||
<th style="width: 160px;">Gruppe</th>
|
<th style="width: 120px;">Opret.</th>
|
||||||
<th style="width: 100px;">Status</th>
|
<th style="width: 120px;">Start arbejde</th>
|
||||||
<th style="width: 120px;">Udsat start</th>
|
<th style="width: 140px;">Start inden</th>
|
||||||
<th style="width: 120px;">Oprettet</th>
|
<th style="width: 120px;">Deadline</th>
|
||||||
<th style="width: 120px;">Opdateret</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="sagTableBody">
|
<tbody id="sagTableBody">
|
||||||
@ -357,7 +382,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="sag-id">#{{ sag.id }}</span>
|
<span class="sag-id">#{{ sag.id }}</span>
|
||||||
</td>
|
</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>
|
<div class="sag-titel">{{ sag.titel }}</div>
|
||||||
{% if sag.beskrivelse %}
|
{% if sag.beskrivelse %}
|
||||||
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
|
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
|
||||||
@ -366,29 +397,26 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
<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>
|
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
{{ sag.priority if sag.priority else 'normal' }}
|
||||||
</td>
|
</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.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;">
|
|
||||||
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||||
</td>
|
</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);">
|
<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 '-' }}
|
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if has_relations %}
|
{% if has_relations %}
|
||||||
@ -402,7 +430,13 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||||
</td>
|
</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 %}
|
{% for rt in all_rel_types %}
|
||||||
<span class="relation-badge">{{ rt }}</span>
|
<span class="relation-badge">{{ rt }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -414,29 +448,26 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
<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>
|
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
||||||
</td>
|
</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.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;">
|
|
||||||
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||||
</td>
|
</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);">
|
<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 '-' }}
|
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -12,15 +12,31 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# APIRouter instance (module_loader kigger efter denne)
|
# APIRouter instance (module_loader kigger efter denne)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Upload directory for logos
|
# Upload directory for logos (works in both Docker and local development)
|
||||||
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
|
_logo_base_dir = os.path.abspath(settings.UPLOAD_DIR)
|
||||||
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
|
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
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from app.core.config import settings
|
|||||||
import httpx
|
import httpx
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -75,7 +76,7 @@ async def get_setting(key: str):
|
|||||||
query = "SELECT * FROM settings WHERE key = %s"
|
query = "SELECT * FROM settings WHERE key = %s"
|
||||||
result = execute_query(query, (key,))
|
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 = """
|
seed_query = """
|
||||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
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,))
|
result = execute_query(query, (key,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
|
|||||||
@ -1143,6 +1143,33 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</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="card p-4 mt-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from typing import Optional, List, Literal
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Tag types
|
# Tag types
|
||||||
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
|
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type']
|
||||||
TagGroupBehavior = Literal['multi', 'single', 'toggle']
|
TagGroupBehavior = Literal['multi', 'single', 'toggle']
|
||||||
|
|
||||||
|
|
||||||
@ -37,6 +37,7 @@ class TagBase(BaseModel):
|
|||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
tag_group_id: Optional[int] = None
|
tag_group_id: Optional[int] = None
|
||||||
|
catch_words: Optional[List[str]] = None
|
||||||
|
|
||||||
class TagCreate(TagBase):
|
class TagCreate(TagBase):
|
||||||
"""Tag creation model"""
|
"""Tag creation model"""
|
||||||
@ -59,6 +60,7 @@ class TagUpdate(BaseModel):
|
|||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
tag_group_id: Optional[int] = None
|
tag_group_id: Optional[int] = None
|
||||||
|
catch_words: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class EntityTagBase(BaseModel):
|
class EntityTagBase(BaseModel):
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Tag system API endpoints
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
import json
|
||||||
from app.tags.backend.models import (
|
from app.tags.backend.models import (
|
||||||
Tag, TagCreate, TagUpdate,
|
Tag, TagCreate, TagUpdate,
|
||||||
EntityTag, EntityTagCreate,
|
EntityTag, EntityTagCreate,
|
||||||
@ -14,6 +15,49 @@ from app.core.database import execute_query, execute_query_single, execute_updat
|
|||||||
|
|
||||||
router = APIRouter(prefix="/tags")
|
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 =============
|
# ============= TAG GROUPS =============
|
||||||
|
|
||||||
@router.get("/groups", response_model=List[TagGroup])
|
@router.get("/groups", response_model=List[TagGroup])
|
||||||
@ -40,7 +84,13 @@ async def list_tags(
|
|||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
):
|
):
|
||||||
"""List all tags with optional filtering"""
|
"""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 = []
|
params = []
|
||||||
|
|
||||||
if type:
|
if type:
|
||||||
@ -54,32 +104,52 @@ async def list_tags(
|
|||||||
query += " ORDER BY type, name"
|
query += " ORDER BY type, name"
|
||||||
|
|
||||||
results = execute_query(query, tuple(params) if params else ())
|
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)
|
@router.get("/{tag_id}", response_model=Tag)
|
||||||
async def get_tag(tag_id: int):
|
async def get_tag(tag_id: int):
|
||||||
"""Get single tag by ID"""
|
"""Get single tag by ID"""
|
||||||
result = execute_query_single(
|
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,)
|
(tag_id,)
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
return result
|
return _tag_row_to_response(result)
|
||||||
|
|
||||||
@router.post("", response_model=Tag)
|
@router.post("", response_model=Tag)
|
||||||
async def create_tag(tag: TagCreate):
|
async def create_tag(tag: TagCreate):
|
||||||
"""Create new tag"""
|
"""Create new tag"""
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
|
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id, catch_words_json)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
catch_words = _normalize_catch_words(tag.catch_words)
|
||||||
result = execute_query_single(
|
result = execute_query_single(
|
||||||
query,
|
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)
|
@router.put("/{tag_id}", response_model=Tag)
|
||||||
async def update_tag(tag_id: int, tag: TagUpdate):
|
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:
|
if tag.tag_group_id is not None:
|
||||||
updates.append("tag_group_id = %s")
|
updates.append("tag_group_id = %s")
|
||||||
params.append(tag.tag_group_id)
|
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:
|
if not updates:
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
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
|
UPDATE tags
|
||||||
SET {', '.join(updates)}
|
SET {', '.join(updates)}
|
||||||
WHERE id = %s
|
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))
|
result = execute_query_single(query, tuple(params))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
return result
|
return _tag_row_to_response(result)
|
||||||
|
|
||||||
@router.delete("/{tag_id}")
|
@router.delete("/{tag_id}")
|
||||||
async def delete_tag(tag_id: int):
|
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):
|
async def get_entity_tags(entity_type: str, entity_id: int):
|
||||||
"""Get all tags for a specific entity"""
|
"""Get all tags for a specific entity"""
|
||||||
query = """
|
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
|
FROM tags t
|
||||||
JOIN entity_tags et ON et.tag_id = t.id
|
JOIN entity_tags et ON et.tag_id = t.id
|
||||||
WHERE et.entity_type = %s AND et.entity_id = %s
|
WHERE et.entity_type = %s AND et.entity_id = %s
|
||||||
ORDER BY t.type, t.name
|
ORDER BY t.type, t.name
|
||||||
"""
|
"""
|
||||||
results = execute_query(query, (entity_type, entity_id))
|
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")
|
@router.get("/search")
|
||||||
async def search_tags(q: str, type: Optional[TagType] = None):
|
async def search_tags(q: str, type: Optional[TagType] = None):
|
||||||
"""Search tags by name (fuzzy search)"""
|
"""Search tags by name (fuzzy search)"""
|
||||||
query = """
|
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
|
WHERE is_active = true
|
||||||
AND LOWER(name) LIKE LOWER(%s)
|
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"
|
query += " ORDER BY name LIMIT 20"
|
||||||
|
|
||||||
results = execute_query(query, tuple(params))
|
results = execute_query(query, tuple(params))
|
||||||
return results
|
return [_tag_row_to_response(row) for row in (results or [])]
|
||||||
|
|
||||||
|
|
||||||
# ============= WORKFLOW MANAGEMENT =============
|
# ============= WORKFLOW MANAGEMENT =============
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
--category-color: #0f4c75;
|
--category-color: #0f4c75;
|
||||||
--priority-color: #dc3545;
|
--priority-color: #dc3545;
|
||||||
--billing-color: #2d6a4f;
|
--billing-color: #2d6a4f;
|
||||||
|
--brand-color: #006d77;
|
||||||
|
--type-color: #5c677d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-badge {
|
.tag-badge {
|
||||||
@ -37,6 +39,8 @@
|
|||||||
.tag-type-category { background-color: var(--category-color); color: white; }
|
.tag-type-category { background-color: var(--category-color); color: white; }
|
||||||
.tag-type-priority { background-color: var(--priority-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-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 {
|
.tag-list-item {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@ -53,6 +57,8 @@
|
|||||||
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); }
|
.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="priority"] { border-left-color: var(--priority-color); }
|
||||||
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-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 {
|
.color-preview {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@ -106,6 +112,16 @@
|
|||||||
<span class="tag-badge tag-type-billing">Billing</span>
|
<span class="tag-badge tag-type-billing">Billing</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<!-- Tags List -->
|
<!-- Tags List -->
|
||||||
@ -148,9 +164,17 @@
|
|||||||
<option value="category">Category - Emne/område</option>
|
<option value="category">Category - Emne/område</option>
|
||||||
<option value="priority">Priority - Hastighed</option>
|
<option value="priority">Priority - Hastighed</option>
|
||||||
<option value="billing">Billing - Økonomi</option>
|
<option value="billing">Billing - Økonomi</option>
|
||||||
|
<option value="brand">Brand - Leverandør/produktbrand</option>
|
||||||
|
<option value="type">Type - Sagstype/arbejdstype</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<label for="tagDescription" class="form-label">Beskrivelse</label>
|
<label for="tagDescription" class="form-label">Beskrivelse</label>
|
||||||
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
|
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
|
||||||
@ -229,7 +253,9 @@
|
|||||||
'status': '#ffd700',
|
'status': '#ffd700',
|
||||||
'category': '#0f4c75',
|
'category': '#0f4c75',
|
||||||
'priority': '#dc3545',
|
'priority': '#dc3545',
|
||||||
'billing': '#2d6a4f'
|
'billing': '#2d6a4f',
|
||||||
|
'brand': '#006d77',
|
||||||
|
'type': '#5c677d'
|
||||||
};
|
};
|
||||||
if (colorMap[type]) {
|
if (colorMap[type]) {
|
||||||
document.getElementById('tagColor').value = colorMap[type];
|
document.getElementById('tagColor').value = colorMap[type];
|
||||||
@ -293,6 +319,7 @@
|
|||||||
${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
|
${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''}
|
${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>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
|
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
|
||||||
@ -315,7 +342,11 @@
|
|||||||
description: document.getElementById('tagDescription').value || null,
|
description: document.getElementById('tagDescription').value || null,
|
||||||
color: document.getElementById('tagColorHex').value,
|
color: document.getElementById('tagColorHex').value,
|
||||||
icon: document.getElementById('tagIcon').value || null,
|
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 {
|
try {
|
||||||
@ -352,6 +383,7 @@
|
|||||||
document.getElementById('tagColorHex').value = tag.color;
|
document.getElementById('tagColorHex').value = tag.color;
|
||||||
document.getElementById('tagIcon').value = tag.icon || '';
|
document.getElementById('tagIcon').value = tag.icon || '';
|
||||||
document.getElementById('tagActive').checked = tag.is_active;
|
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';
|
document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
|
||||||
new bootstrap.Modal(document.getElementById('createTagModal')).show();
|
new bootstrap.Modal(document.getElementById('createTagModal')).show();
|
||||||
|
|||||||
@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-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">
|
<table class="table table-sm table-hover mb-0" id="caseTable">
|
||||||
<thead class="table-light" id="tableHead">
|
<thead class="table-light" id="tableHead">
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>SagsID</th>
|
||||||
<th>Titel</th>
|
<th>Virksom.</th>
|
||||||
<th>Kunde</th>
|
<th>Kontakt</th>
|
||||||
<th>Status</th>
|
<th>Beskr.</th>
|
||||||
<th>Dato</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tableBody">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -167,8 +189,16 @@ const allData = {
|
|||||||
{
|
{
|
||||||
id: {{ item.id }},
|
id: {{ item.id }},
|
||||||
titel: {{ item.titel | tojson | safe }},
|
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 }},
|
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' }},
|
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' }},
|
status: {{ item.status | tojson | safe if item.status else 'null' }},
|
||||||
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
||||||
}{% if not loop.last %},{% endif %}
|
}{% if not loop.last %},{% endif %}
|
||||||
@ -179,7 +209,16 @@ const allData = {
|
|||||||
{
|
{
|
||||||
id: {{ item.id }},
|
id: {{ item.id }},
|
||||||
titel: {{ item.titel | tojson | safe }},
|
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 }},
|
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' }},
|
status: {{ item.status | tojson | safe if item.status else 'null' }},
|
||||||
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
||||||
}{% if not loop.last %},{% endif %}
|
}{% if not loop.last %},{% endif %}
|
||||||
@ -191,9 +230,16 @@ const allData = {
|
|||||||
item_type: {{ item.item_type | tojson | safe }},
|
item_type: {{ item.item_type | tojson | safe }},
|
||||||
item_id: {{ item.item_id }},
|
item_id: {{ item.item_id }},
|
||||||
title: {{ item.title | tojson | safe }},
|
title: {{ item.title | tojson | safe }},
|
||||||
|
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
|
||||||
customer_name: {{ item.customer_name | tojson | safe }},
|
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' }},
|
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' }},
|
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' }},
|
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
|
||||||
status: {{ item.status | tojson | safe if item.status else 'null' }}
|
status: {{ item.status | tojson | safe if item.status else 'null' }}
|
||||||
}{% if not loop.last %},{% endif %}
|
}{% if not loop.last %},{% endif %}
|
||||||
@ -205,7 +251,16 @@ const allData = {
|
|||||||
id: {{ item.id }},
|
id: {{ item.id }},
|
||||||
titel: {{ item.titel | tojson | safe }},
|
titel: {{ item.titel | tojson | safe }},
|
||||||
group_name: {{ item.group_name | 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 }},
|
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' }},
|
status: {{ item.status | tojson | safe if item.status else 'null' }},
|
||||||
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
||||||
}{% if not loop.last %},{% endif %}
|
}{% 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' });
|
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) {
|
function toggleSection(filterName) {
|
||||||
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
|
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
|
||||||
const listTitle = document.getElementById('listTitle');
|
const listTitle = document.getElementById('listTitle');
|
||||||
@ -242,7 +323,7 @@ function toggleSection(filterName) {
|
|||||||
if (currentFilter === filterName) {
|
if (currentFilter === filterName) {
|
||||||
currentFilter = null;
|
currentFilter = null;
|
||||||
listTitle.textContent = 'Alle sager';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,70 +347,43 @@ function filterAndPopulateTable(filterName) {
|
|||||||
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
|
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
|
||||||
const data = allData.newCases || [];
|
const data = allData.newCases || [];
|
||||||
if (data.length === 0) {
|
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 {
|
} else {
|
||||||
bodyHTML = data.map(item => `
|
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
|
||||||
<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('');
|
|
||||||
}
|
}
|
||||||
} else if (filterName === 'myCases') {
|
} else if (filterName === 'myCases') {
|
||||||
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
|
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
|
||||||
const data = allData.myCases || [];
|
const data = allData.myCases || [];
|
||||||
if (data.length === 0) {
|
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 {
|
} else {
|
||||||
bodyHTML = data.map(item => `
|
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
|
||||||
<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('');
|
|
||||||
}
|
}
|
||||||
} else if (filterName === 'todayTasks') {
|
} else if (filterName === 'todayTasks') {
|
||||||
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
|
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
|
||||||
const data = allData.todayTasks || [];
|
const data = allData.todayTasks || [];
|
||||||
if (data.length === 0) {
|
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 {
|
} else {
|
||||||
bodyHTML = data.map(item => {
|
bodyHTML = data.map(item => {
|
||||||
const badge = item.item_type === 'case'
|
const normalized = {
|
||||||
? '<span class="badge bg-primary">Sag</span>'
|
...item,
|
||||||
: '<span class="badge bg-info">Ticket</span>';
|
id: item.item_id,
|
||||||
return `
|
titel: item.title,
|
||||||
<tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
|
beskrivelse: item.task_reason || item.beskrivelse,
|
||||||
<td>#${item.item_id}</td>
|
deadline: item.deadline || item.due_at,
|
||||||
<td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
|
case_type: item.case_type || item.item_type
|
||||||
<td>${item.customer_name || '-'}</td>
|
};
|
||||||
<td>${badge}</td>
|
return renderCaseTableRow(normalized, 'id', 'item_type');
|
||||||
<td>${formatDate(item.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
} else if (filterName === 'groupCases') {
|
} else if (filterName === 'groupCases') {
|
||||||
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
|
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
|
||||||
const data = allData.groupCases || [];
|
const data = allData.groupCases || [];
|
||||||
if (data.length === 0) {
|
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 {
|
} else {
|
||||||
bodyHTML = data.map(item => `
|
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
|
||||||
<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('');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,14 +86,38 @@
|
|||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover mb-0">
|
<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>
|
<tbody>
|
||||||
{% for item in new_cases %}
|
{% for item in new_cases %}
|
||||||
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
|
<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>
|
</tr>
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -32,59 +32,71 @@
|
|||||||
<table class="table table-hover table-sm mb-0 align-middle">
|
<table class="table table-hover table-sm mb-0 align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Virksom.</th>
|
||||||
|
<th>Kontakt</th>
|
||||||
|
<th>Beskr.</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>ID</th>
|
<th>Ansvarl.</th>
|
||||||
<th>Titel</th>
|
<th>Gruppe/Level</th>
|
||||||
<th>Kunde</th>
|
<th>Opret.</th>
|
||||||
<th>Status</th>
|
<th>Start arbejde</th>
|
||||||
<th>Prioritet/Reason</th>
|
<th>Start inden</th>
|
||||||
<th>Deadline</th>
|
<th>Deadline</th>
|
||||||
<th>Handling</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in urgent_overdue %}
|
{% for item in urgent_overdue %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="badge bg-danger">Haste</span></td>
|
|
||||||
<td>#{{ item.item_id }}</td>
|
<td>#{{ item.item_id }}</td>
|
||||||
<td>{{ item.title }}</td>
|
<td>{{ item.customer_name or '-' }}</td>
|
||||||
<td>{{ item.customer_name }}</td>
|
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||||
<td>{{ item.status }}</td>
|
<td>{{ item.beskrivelse or item.title or '-' }}</td>
|
||||||
<td>{{ item.attention_reason }}</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>{{ 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for item in today_tasks %}
|
{% for item in today_tasks %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="badge bg-primary">I dag</span></td>
|
|
||||||
<td>#{{ item.item_id }}</td>
|
<td>#{{ item.item_id }}</td>
|
||||||
<td>{{ item.title }}</td>
|
<td>{{ item.customer_name or '-' }}</td>
|
||||||
<td>{{ item.customer_name }}</td>
|
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||||
<td>{{ item.status }}</td>
|
<td>{{ item.beskrivelse or item.title or item.task_reason or '-' }}</td>
|
||||||
<td>{{ item.task_reason }}</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>{{ 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for item in my_cases %}
|
{% for item in my_cases %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="badge bg-secondary">Min sag</span></td>
|
|
||||||
<td>#{{ item.id }}</td>
|
<td>#{{ item.id }}</td>
|
||||||
<td>{{ item.titel }}</td>
|
<td>{{ item.customer_name or '-' }}</td>
|
||||||
<td>{{ item.customer_name }}</td>
|
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||||
<td>{{ item.status }}</td>
|
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
|
||||||
<td>-</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>{{ 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if not urgent_overdue and not today_tasks and not my_cases %}
|
{% if not urgent_overdue and not today_tasks and not my_cases %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import date
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -18,6 +18,20 @@ router = APIRouter()
|
|||||||
templates = Jinja2Templates(directory="app")
|
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)
|
@router.get("/", include_in_schema=False)
|
||||||
async def ticket_root_redirect():
|
async def ticket_root_redirect():
|
||||||
return RedirectResponse(url="/sag", status_code=302)
|
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]:
|
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||||
"""Collect live data slices for technician-focused dashboard variants."""
|
"""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 = """
|
user_query = """
|
||||||
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
|
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
|
||||||
FROM users
|
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,))
|
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}"
|
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
|
||||||
|
|
||||||
new_cases_query = """
|
new_cases_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
s.titel,
|
s.titel,
|
||||||
|
s.beskrivelse,
|
||||||
|
s.priority,
|
||||||
s.status,
|
s.status,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
|
{case_start_date_sql},
|
||||||
|
s.deferred_until,
|
||||||
s.deadline,
|
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
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
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
|
WHERE s.deleted_at IS NULL
|
||||||
AND s.status = 'åben'
|
AND s.status = 'åben'
|
||||||
ORDER BY s.created_at DESC
|
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)
|
new_cases = execute_query(new_cases_query)
|
||||||
|
|
||||||
my_cases_query = """
|
my_cases_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
s.titel,
|
s.titel,
|
||||||
|
s.beskrivelse,
|
||||||
|
s.priority,
|
||||||
s.status,
|
s.status,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
|
{case_start_date_sql},
|
||||||
|
s.deferred_until,
|
||||||
s.deadline,
|
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
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
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
|
WHERE s.deleted_at IS NULL
|
||||||
AND s.ansvarlig_bruger_id = %s
|
AND s.ansvarlig_bruger_id = %s
|
||||||
AND s.status <> 'lukket'
|
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,))
|
my_cases = execute_query(my_cases_query, (technician_user_id,))
|
||||||
|
|
||||||
today_tasks_query = """
|
today_tasks_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
'case' AS item_type,
|
'case' AS item_type,
|
||||||
s.id AS item_id,
|
s.id AS item_id,
|
||||||
s.titel AS title,
|
s.titel AS title,
|
||||||
|
s.beskrivelse,
|
||||||
s.status,
|
s.status,
|
||||||
s.deadline AS due_at,
|
s.deadline AS due_at,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
|
{case_start_date_sql},
|
||||||
|
s.deferred_until,
|
||||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
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
|
'Sag deadline i dag' AS task_reason
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
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
|
WHERE s.deleted_at IS NULL
|
||||||
AND s.ansvarlig_bruger_id = %s
|
AND s.ansvarlig_bruger_id = %s
|
||||||
AND s.status <> 'lukket'
|
AND s.status <> 'lukket'
|
||||||
@ -430,14 +499,22 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
|||||||
'ticket' AS item_type,
|
'ticket' AS item_type,
|
||||||
t.id AS item_id,
|
t.id AS item_id,
|
||||||
t.subject AS title,
|
t.subject AS title,
|
||||||
|
NULL::text AS beskrivelse,
|
||||||
t.status,
|
t.status,
|
||||||
NULL::date AS due_at,
|
NULL::date AS due_at,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
|
NULL::date AS start_date,
|
||||||
|
NULL::date AS deferred_until,
|
||||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
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,
|
COALESCE(t.priority, 'normal') AS priority,
|
||||||
'Ticket oprettet i dag' AS task_reason
|
'Ticket oprettet i dag' AS task_reason
|
||||||
FROM tticket_tickets t
|
FROM tticket_tickets t
|
||||||
LEFT JOIN customers c ON c.id = t.customer_id
|
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
|
WHERE t.assigned_to_user_id = %s
|
||||||
AND t.status IN ('open', 'in_progress', 'pending_customer')
|
AND t.status IN ('open', 'in_progress', 'pending_customer')
|
||||||
AND DATE(t.created_at) = CURRENT_DATE
|
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))
|
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
|
||||||
|
|
||||||
urgent_overdue_query = """
|
urgent_overdue_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
'case' AS item_type,
|
'case' AS item_type,
|
||||||
s.id AS item_id,
|
s.id AS item_id,
|
||||||
s.titel AS title,
|
s.titel AS title,
|
||||||
|
s.beskrivelse,
|
||||||
s.status,
|
s.status,
|
||||||
s.deadline AS due_at,
|
s.deadline AS due_at,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
|
{case_start_date_sql},
|
||||||
|
s.deferred_until,
|
||||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
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,
|
NULL::text AS priority,
|
||||||
'Over deadline' AS attention_reason
|
'Over deadline' AS attention_reason
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
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
|
WHERE s.deleted_at IS NULL
|
||||||
AND s.status <> 'lukket'
|
AND s.status <> 'lukket'
|
||||||
AND s.deadline IS NOT NULL
|
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,
|
'ticket' AS item_type,
|
||||||
t.id AS item_id,
|
t.id AS item_id,
|
||||||
t.subject AS title,
|
t.subject AS title,
|
||||||
|
NULL::text AS beskrivelse,
|
||||||
t.status,
|
t.status,
|
||||||
NULL::date AS due_at,
|
NULL::date AS due_at,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
|
NULL::date AS start_date,
|
||||||
|
NULL::date AS deferred_until,
|
||||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
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,
|
COALESCE(t.priority, 'normal') AS priority,
|
||||||
CASE
|
CASE
|
||||||
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
|
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
|
END AS attention_reason
|
||||||
FROM tticket_tickets t
|
FROM tticket_tickets t
|
||||||
LEFT JOIN customers c ON c.id = t.customer_id
|
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')
|
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
|
||||||
AND COALESCE(t.priority, '') IN ('urgent', 'high')
|
AND COALESCE(t.priority, '') IN ('urgent', 'high')
|
||||||
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
|
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)
|
# Get group cases (cases assigned to user's groups)
|
||||||
group_cases = []
|
group_cases = []
|
||||||
if user_group_ids:
|
if user_group_ids:
|
||||||
group_cases_query = """
|
group_cases_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
s.titel,
|
s.titel,
|
||||||
|
s.beskrivelse,
|
||||||
|
s.priority,
|
||||||
s.status,
|
s.status,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
|
{case_start_date_sql},
|
||||||
|
s.deferred_until,
|
||||||
s.deadline,
|
s.deadline,
|
||||||
|
{case_type_sql},
|
||||||
s.assigned_group_id,
|
s.assigned_group_id,
|
||||||
g.name AS group_name,
|
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
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
LEFT JOIN groups g ON g.id = s.assigned_group_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
|
WHERE s.deleted_at IS NULL
|
||||||
AND s.assigned_group_id = ANY(%s)
|
AND s.assigned_group_id = ANY(%s)
|
||||||
AND s.status <> 'lukket'
|
AND s.status <> 'lukket'
|
||||||
|
|||||||
38
main.py
38
main.py
@ -16,6 +16,29 @@ from app.core.database import init_db
|
|||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
from app.core.database import execute_query_single
|
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():
|
def get_version():
|
||||||
"""Read version from VERSION file"""
|
"""Read version from VERSION file"""
|
||||||
try:
|
try:
|
||||||
@ -265,11 +288,16 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
content={"detail": "Invalid token"}
|
content={"detail": "Invalid token"}
|
||||||
)
|
)
|
||||||
user_id = int(payload.get("sub"))
|
user_id = int(payload.get("sub"))
|
||||||
user = execute_query_single(
|
|
||||||
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
if _users_column_exists("is_2fa_enabled"):
|
||||||
(user_id,)
|
user = execute_query_single(
|
||||||
)
|
"SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
|
||||||
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
|
(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:
|
if not is_2fa_enabled:
|
||||||
allowed_2fa_paths = (
|
allowed_2fa_paths = (
|
||||||
|
|||||||
@ -37,7 +37,7 @@ CREATE TABLE email_rules (
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by_user_id INTEGER,
|
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)
|
-- Email Messages Table (main storage)
|
||||||
@ -183,7 +183,7 @@ SELECT
|
|||||||
COUNT(ea.id) as attachment_count_actual,
|
COUNT(ea.id) as attachment_count_actual,
|
||||||
er.name as rule_name,
|
er.name as rule_name,
|
||||||
v.name as supplier_name,
|
v.name as supplier_name,
|
||||||
tc.customer_name,
|
tc.name as customer_name,
|
||||||
tcase.title as case_title
|
tcase.title as case_title
|
||||||
FROM email_messages em
|
FROM email_messages em
|
||||||
LEFT JOIN email_attachments ea ON em.id = ea.email_id
|
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
|
LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id
|
||||||
WHERE em.deleted_at IS NULL
|
WHERE em.deleted_at IS NULL
|
||||||
AND em.status IN ('new', 'error')
|
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;
|
ORDER BY em.received_date DESC;
|
||||||
|
|
||||||
-- View for recent email activity
|
-- View for recent email activity
|
||||||
|
|||||||
@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS tticket_relations (
|
|||||||
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
|
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
|
CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id);
|
||||||
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
|
CREATE INDEX IF NOT EXISTS 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_type ON tticket_relations(relation_type);
|
||||||
|
|
||||||
-- View for at finde alle relationer for en ticket (begge retninger)
|
-- View for at finde alle relationer for en ticket (begge retninger)
|
||||||
CREATE OR REPLACE VIEW tticket_all_relations AS
|
CREATE OR REPLACE VIEW tticket_all_relations AS
|
||||||
@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS tticket_calendar_events (
|
|||||||
completed_at TIMESTAMP
|
completed_at TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
|
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
|
||||||
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
|
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date);
|
||||||
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
|
CREATE INDEX IF NOT EXISTS 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_status ON tticket_calendar_events(status);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- TEMPLATES (svarskabeloner, guides, standardbreve)
|
-- TEMPLATES (svarskabeloner, guides, standardbreve)
|
||||||
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
|
|||||||
usage_count INTEGER DEFAULT 0
|
usage_count INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
|
CREATE INDEX IF NOT EXISTS 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_active ON tticket_templates(is_active);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
|
-- 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?
|
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 IF NOT EXISTS 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_ticket ON tticket_template_usage(ticket_id);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
|
-- 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
|
expires_at TIMESTAMP -- Forslag udløber efter X dage
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
|
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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_created ON tticket_ai_suggestions(created_at);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- EMAIL METADATA (udvidet til contact identification)
|
-- EMAIL METADATA (udvidet til contact identification)
|
||||||
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
|
|||||||
updated_at TIMESTAMP
|
updated_at TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
|
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS 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_from ON tticket_email_metadata(from_email);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Tilføj manglende kolonner til existing tticket_tickets
|
-- Tilføj manglende kolonner til existing tticket_tickets
|
||||||
@ -265,9 +265,15 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
|
|||||||
metadata JSONB -- Additional context
|
metadata JSONB -- Additional context
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
|
ALTER TABLE tticket_audit_log
|
||||||
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
|
ADD COLUMN IF NOT EXISTS field_name VARCHAR(100),
|
||||||
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
|
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
|
-- TRIGGERS for audit logging
|
||||||
|
|||||||
@ -24,7 +24,17 @@ ADD COLUMN IF NOT EXISTS time_date DATE;
|
|||||||
ALTER TABLE tmodule_order_lines
|
ALTER TABLE tmodule_order_lines
|
||||||
ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false;
|
ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
-- Log migration
|
-- Log migration when the legacy tracking table exists
|
||||||
INSERT INTO migration_log (migration_name, applied_at)
|
DO $$
|
||||||
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
|
BEGIN
|
||||||
ON CONFLICT DO NOTHING;
|
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 $$;
|
||||||
|
|||||||
@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
-- Add import_method column
|
-- Add import_method column
|
||||||
ALTER TABLE email_messages
|
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
|
-- Add comment
|
||||||
COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload';
|
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 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
|
-- Update existing records to reflect their actual source
|
||||||
-- (all existing emails were fetched via IMAP or Graph API)
|
-- (all existing emails were fetched via IMAP or Graph API)
|
||||||
@ -19,6 +19,9 @@ SET import_method = 'imap'
|
|||||||
WHERE import_method IS NULL;
|
WHERE import_method IS NULL;
|
||||||
|
|
||||||
-- Add constraint to ensure valid values
|
-- Add constraint to ensure valid values
|
||||||
|
ALTER TABLE email_messages
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_email_import_method;
|
||||||
|
|
||||||
ALTER TABLE email_messages
|
ALTER TABLE email_messages
|
||||||
ADD CONSTRAINT chk_email_import_method
|
ADD CONSTRAINT chk_email_import_method
|
||||||
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));
|
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
-- 069_conversation_category.sql
|
-- 069_conversation_category.sql
|
||||||
-- Add category column for conversation classification
|
-- 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';
|
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
-- 072_add_category_to_conversations.sql
|
-- 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)';
|
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';
|
||||||
|
|||||||
@ -11,4 +11,4 @@ CREATE TABLE IF NOT EXISTS sag_kommentarer (
|
|||||||
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
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);
|
||||||
|
|||||||
@ -51,7 +51,7 @@ SELECT
|
|||||||
s.customer_id,
|
s.customer_id,
|
||||||
cust.name as customer_name,
|
cust.name as customer_name,
|
||||||
s.sag_id,
|
s.sag_id,
|
||||||
sag.title as sag_title,
|
sag.titel as sag_title,
|
||||||
s.session_link,
|
s.session_link,
|
||||||
s.started_at,
|
s.started_at,
|
||||||
s.ended_at,
|
s.ended_at,
|
||||||
|
|||||||
39
migrations/144_tags_brand_type_catch_words.sql
Normal file
39
migrations/144_tags_brand_type_catch_words.sql
Normal 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';
|
||||||
51
migrations/145_seed_brand_tags_a_z.sql
Normal file
51
migrations/145_seed_brand_tags_a_z.sql
Normal 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;
|
||||||
101
migrations/146_seed_type_tags_case_types.sql
Normal file
101
migrations/146_seed_type_tags_case_types.sql
Normal 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;
|
||||||
148
migrations/147_seed_brand_and_type_tags_master.sql
Normal file
148
migrations/147_seed_brand_and_type_tags_master.sql
Normal 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;
|
||||||
5
migrations/148_sag_todo_next_flag.sql
Normal file
5
migrations/148_sag_todo_next_flag.sql
Normal 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
159
scripts/run_migrations.py
Normal 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())
|
||||||
@ -212,10 +212,12 @@ class TagPicker {
|
|||||||
'status': '📊 Status - Tilstand',
|
'status': '📊 Status - Tilstand',
|
||||||
'category': '📁 Kategori - Emne',
|
'category': '📁 Kategori - Emne',
|
||||||
'priority': '🔥 Prioritet - Hastighed',
|
'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 = '';
|
let html = '';
|
||||||
typeOrder.forEach(type => {
|
typeOrder.forEach(type => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user