diff --git a/app/modules/manual/__init__.py b/app/modules/manual/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/manual/backend/__init__.py b/app/modules/manual/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/manual/backend/router.py b/app/modules/manual/backend/router.py new file mode 100644 index 0000000..c5e39bf --- /dev/null +++ b/app/modules/manual/backend/router.py @@ -0,0 +1,414 @@ +import logging +import json +import re +from typing import Any, Dict, List, Literal, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from app.core.database import execute_query, execute_query_single, execute_update + +logger = logging.getLogger(__name__) +router = APIRouter() + +DifficultyType = Literal["beginner", "advanced"] + + +class ManualStepInput(BaseModel): + step_number: int = Field(ge=1) + title: str = Field(min_length=1, max_length=255) + content: str = Field(min_length=1) + image_url: Optional[str] = None + video_url: Optional[str] = None + + +class ManualRelationInput(BaseModel): + related_module: Optional[str] = Field(default=None, max_length=80) + related_tag: Optional[str] = Field(default=None, max_length=120) + related_sag_type: Optional[str] = Field(default=None, max_length=120) + related_manual_id: Optional[str] = None + + +class ManualArticleCreate(BaseModel): + title: str = Field(min_length=1, max_length=255) + slug: Optional[str] = Field(default=None, max_length=255) + content: str = Field(min_length=1) + summary: Optional[str] = None + module: str = Field(min_length=1, max_length=80) + tags: List[str] = Field(default_factory=list) + difficulty: DifficultyType = "beginner" + steps: List[ManualStepInput] = Field(default_factory=list) + relations: List[ManualRelationInput] = Field(default_factory=list) + + +class ManualArticleUpdate(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + slug: Optional[str] = Field(default=None, max_length=255) + content: Optional[str] = Field(default=None, min_length=1) + summary: Optional[str] = None + module: Optional[str] = Field(default=None, min_length=1, max_length=80) + tags: Optional[List[str]] = None + difficulty: Optional[DifficultyType] = None + steps: Optional[List[ManualStepInput]] = None + relations: Optional[List[ManualRelationInput]] = None + + +def _slugify(value: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9\s-]", "", value or "").strip().lower() + cleaned = re.sub(r"[-\s]+", "-", cleaned) + return cleaned[:255] or "manual" + + +def _normalize_tags(tags: Optional[List[str]]) -> List[str]: + if not tags: + return [] + out: List[str] = [] + seen = set() + for tag in tags: + clean = str(tag or "").strip().lower() + if not clean: + continue + if clean in seen: + continue + seen.add(clean) + out.append(clean) + return out + + +def _next_unique_slug(base_slug: str, exclude_id: Optional[str] = None) -> str: + candidate = _slugify(base_slug) + suffix = 1 + while True: + if exclude_id: + row = execute_query_single( + "SELECT id FROM manual_articles WHERE slug = %s AND id <> %s AND deleted_at IS NULL", + (candidate, exclude_id), + ) + else: + row = execute_query_single( + "SELECT id FROM manual_articles WHERE slug = %s AND deleted_at IS NULL", + (candidate,), + ) + if not row: + return candidate + candidate = f"{_slugify(base_slug)}-{suffix}" + suffix += 1 + + +def _fetch_article_by_id(article_id: str) -> Optional[Dict[str, Any]]: + row = execute_query_single( + """ + SELECT id, title, slug, content, summary, module, tags, difficulty, + use_count, created_at, updated_at + FROM manual_articles + WHERE id = %s AND deleted_at IS NULL + """, + (article_id,), + ) + if not row: + return None + return _expand_article(row) + + +def _expand_article(row: Dict[str, Any]) -> Dict[str, Any]: + article = dict(row) + steps = execute_query( + """ + SELECT id, step_number, title, content, image_url, video_url + FROM manual_steps + WHERE manual_id = %s + ORDER BY step_number ASC + """, + (article["id"],), + ) or [] + + relations = execute_query( + """ + SELECT + mr.id, + mr.related_module, + mr.related_tag, + mr.related_sag_type, + mr.related_manual_id, + rm.slug AS related_manual_slug, + rm.title AS related_manual_title + FROM manual_relations mr + LEFT JOIN manual_articles rm + ON rm.id = mr.related_manual_id + AND rm.deleted_at IS NULL + WHERE mr.manual_id = %s + ORDER BY rm.use_count DESC NULLS LAST, rm.updated_at DESC NULLS LAST + """, + (article["id"],), + ) or [] + + article["steps"] = steps + article["relations"] = relations + return article + + +def _replace_steps(article_id: str, steps: List[ManualStepInput]) -> None: + execute_update("DELETE FROM manual_steps WHERE manual_id = %s", (article_id,)) + for step in sorted(steps, key=lambda s: s.step_number): + execute_query( + """ + INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + article_id, + step.step_number, + step.title, + step.content, + step.image_url, + step.video_url, + ), + ) + + +def _replace_relations(article_id: str, relations: List[ManualRelationInput]) -> None: + execute_update("DELETE FROM manual_relations WHERE manual_id = %s", (article_id,)) + for relation in relations: + execute_query( + """ + INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id) + VALUES (%s, %s, %s, %s, %s) + """, + ( + article_id, + relation.related_module, + relation.related_tag, + relation.related_sag_type, + relation.related_manual_id, + ), + ) + + +@router.get("/manual") +async def list_manual_articles( + module: Optional[str] = Query(default=None), + difficulty: Optional[DifficultyType] = Query(default=None), + tags: Optional[str] = Query(default=None, description="Comma-separated tags"), + search: Optional[str] = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +): + where_parts = ["deleted_at IS NULL"] + params: List[Any] = [] + + if module: + where_parts.append("LOWER(module) = LOWER(%s)") + params.append(module.strip()) + + if difficulty: + where_parts.append("difficulty = %s") + params.append(difficulty) + + if tags: + tag_list = _normalize_tags([t.strip() for t in tags.split(",") if t.strip()]) + if tag_list: + where_parts.append( + "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE t.tag = ANY(%s::text[]))" + ) + params.append(tag_list) + + if search: + where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)") + needle = f"%{search.strip()}%" + params.extend([needle, needle, needle]) + + query = f""" + SELECT id, title, slug, summary, module, tags, difficulty, use_count, created_at, updated_at + FROM manual_articles + WHERE {' AND '.join(where_parts)} + ORDER BY use_count DESC, updated_at DESC, created_at DESC + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + + items = execute_query(query, tuple(params)) or [] + return {"items": items, "count": len(items)} + + +@router.get("/manual/context") +async def contextual_manual_suggestions( + module: Optional[str] = Query(default=None), + tag: Optional[str] = Query(default=None), + sag_type: Optional[str] = Query(default=None), + limit: int = Query(default=10, ge=1, le=50), +): + where_parts = ["ma.deleted_at IS NULL"] + params: List[Any] = [] + + if module: + where_parts.append("(LOWER(ma.module) = LOWER(%s) OR LOWER(mr.related_module) = LOWER(%s))") + params.extend([module.strip(), module.strip()]) + + if tag: + where_parts.append( + "(LOWER(mr.related_tag) = LOWER(%s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(ma.tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s)))" + ) + params.extend([tag.strip(), tag.strip()]) + + if sag_type: + where_parts.append("LOWER(mr.related_sag_type) = LOWER(%s)") + params.append(sag_type.strip()) + + query = f""" + SELECT DISTINCT + ma.id, + ma.title, + ma.slug, + ma.summary, + ma.module, + ma.tags, + ma.difficulty, + ma.use_count, + ma.updated_at + FROM manual_articles ma + LEFT JOIN manual_relations mr ON mr.manual_id = ma.id + WHERE {' AND '.join(where_parts)} + ORDER BY ma.use_count DESC, ma.updated_at DESC + LIMIT %s + """ + params.append(limit) + + items = execute_query(query, tuple(params)) or [] + return {"items": items, "count": len(items)} + + +@router.get("/manual/{slug}") +async def get_manual_article(slug: str): + article = execute_query_single( + """ + SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at + FROM manual_articles + WHERE slug = %s AND deleted_at IS NULL + """, + (slug,), + ) + if not article: + raise HTTPException(status_code=404, detail="Manual not found") + + execute_update( + "UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s", + (article["id"],), + ) + + # Read the refreshed count for consistency in response. + article["use_count"] = int(article.get("use_count") or 0) + 1 + return _expand_article(article) + + +@router.post("/manual") +async def create_manual_article(payload: ManualArticleCreate): + try: + wanted_slug = payload.slug or payload.title + slug = _next_unique_slug(wanted_slug) + + created = execute_query_single( + """ + INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty) + VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s) + RETURNING id + """, + ( + payload.title.strip(), + slug, + payload.content, + payload.summary, + payload.module.strip().lower(), + json.dumps(_normalize_tags(payload.tags), ensure_ascii=False), + payload.difficulty, + ), + ) + if not created: + raise HTTPException(status_code=500, detail="Could not create manual") + + article_id = created["id"] + if payload.steps: + _replace_steps(article_id, payload.steps) + if payload.relations: + _replace_relations(article_id, payload.relations) + + article = _fetch_article_by_id(article_id) + if not article: + raise HTTPException(status_code=500, detail="Could not load created manual") + return article + except HTTPException: + raise + except Exception as e: + logger.error("❌ Failed to create manual article: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to create manual") + + +@router.put("/manual/{article_id}") +async def update_manual_article(article_id: str, payload: ManualArticleUpdate): + existing = execute_query_single( + "SELECT id, title, slug FROM manual_articles WHERE id = %s AND deleted_at IS NULL", + (article_id,), + ) + if not existing: + raise HTTPException(status_code=404, detail="Manual not found") + + updates: List[str] = [] + params: List[Any] = [] + + if payload.title is not None: + updates.append("title = %s") + params.append(payload.title.strip()) + + if payload.content is not None: + updates.append("content = %s") + params.append(payload.content) + + if payload.summary is not None: + updates.append("summary = %s") + params.append(payload.summary) + + if payload.module is not None: + updates.append("module = %s") + params.append(payload.module.strip().lower()) + + if payload.difficulty is not None: + updates.append("difficulty = %s") + params.append(payload.difficulty) + + if payload.tags is not None: + updates.append("tags = %s::jsonb") + params.append(json.dumps(_normalize_tags(payload.tags), ensure_ascii=False)) + + if payload.slug is not None or payload.title is not None: + slug_source = payload.slug or payload.title or existing["slug"] + unique_slug = _next_unique_slug(slug_source, exclude_id=article_id) + updates.append("slug = %s") + params.append(unique_slug) + + if updates: + params.append(article_id) + execute_update( + f"UPDATE manual_articles SET {', '.join(updates)} WHERE id = %s", + tuple(params), + ) + + if payload.steps is not None: + _replace_steps(article_id, payload.steps) + + if payload.relations is not None: + _replace_relations(article_id, payload.relations) + + article = _fetch_article_by_id(article_id) + if not article: + raise HTTPException(status_code=500, detail="Could not load updated manual") + return article + + +@router.delete("/manual/{article_id}") +async def delete_manual_article(article_id: str): + affected = execute_update( + "UPDATE manual_articles SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL", + (article_id,), + ) + if affected == 0: + raise HTTPException(status_code=404, detail="Manual not found") + return {"success": True, "deleted_id": article_id} diff --git a/app/modules/manual/frontend/__init__.py b/app/modules/manual/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/manual/frontend/views.py b/app/modules/manual/frontend/views.py new file mode 100644 index 0000000..78e73fe --- /dev/null +++ b/app/modules/manual/frontend/views.py @@ -0,0 +1,174 @@ +import logging +import html +import re +from typing import Any, List, Optional + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from app.core.database import execute_query, execute_query_single + +logger = logging.getLogger(__name__) +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +def _normalize_tag_list(value: Any) -> List[str]: + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + return [] + + +def _normalize_manual_text(value: Any) -> str: + text = str(value or "") + text = html.unescape(text) + text = re.sub(r"(?i)", "\n", text) + text = text.replace("\\n", "\n") + return text + + +@router.get("/manual", response_class=HTMLResponse) +async def manual_index( + request: Request, + module: Optional[str] = Query(default=None), + difficulty: Optional[str] = Query(default=None), + tag: Optional[str] = Query(default=None), + search: Optional[str] = Query(default=None), +): + where_parts = ["deleted_at IS NULL"] + params: List[Any] = [] + + if module: + where_parts.append("LOWER(module) = LOWER(%s)") + params.append(module.strip()) + + if difficulty in {"beginner", "advanced"}: + where_parts.append("difficulty = %s") + params.append(difficulty) + + if search: + needle = f"%{search.strip()}%" + where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)") + params.extend([needle, needle, needle]) + + if tag: + where_parts.append( + "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s))" + ) + params.append(tag.strip()) + + rows = execute_query( + f""" + SELECT id, title, slug, summary, module, difficulty, tags, use_count, updated_at + FROM manual_articles + WHERE {' AND '.join(where_parts)} + ORDER BY use_count DESC, updated_at DESC + LIMIT 300 + """, + tuple(params), + ) or [] + + modules = execute_query( + """ + SELECT DISTINCT module + FROM manual_articles + WHERE deleted_at IS NULL + ORDER BY module ASC + """ + ) or [] + + all_tags: List[str] = [] + for row in rows: + all_tags.extend(_normalize_tag_list(row.get("tags"))) + unique_tags = sorted(list(set(all_tags))) + + return templates.TemplateResponse( + "modules/manual/templates/list.html", + { + "request": request, + "articles": rows, + "available_modules": modules, + "available_tags": unique_tags, + "filters": { + "module": module or "", + "difficulty": difficulty or "", + "tag": tag or "", + "search": search or "", + }, + }, + ) + + +@router.get("/manual/admin", response_class=HTMLResponse) +async def manual_admin(request: Request): + articles = execute_query( + """ + SELECT id, title, slug, module, difficulty, use_count, updated_at + FROM manual_articles + WHERE deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT 200 + """ + ) or [] + return templates.TemplateResponse( + "modules/manual/templates/admin.html", + {"request": request, "articles": articles}, + ) + + +@router.get("/manual/{slug}", response_class=HTMLResponse) +async def manual_detail(request: Request, slug: str): + article = execute_query_single( + """ + SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at + FROM manual_articles + WHERE slug = %s AND deleted_at IS NULL + """, + (slug,), + ) + + if not article: + return templates.TemplateResponse( + "modules/manual/templates/detail.html", + {"request": request, "article": None, "steps": [], "related": []}, + status_code=404, + ) + + execute_query( + "UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s", + (article["id"],), + ) + article["use_count"] = int(article.get("use_count") or 0) + 1 + article["content_normalized"] = _normalize_manual_text(article.get("content")) + article["summary_normalized"] = _normalize_manual_text(article.get("summary")) + + steps = execute_query( + """ + SELECT step_number, title, content, image_url, video_url + FROM manual_steps + WHERE manual_id = %s + ORDER BY step_number ASC + """, + (article["id"],), + ) or [] + for step in steps: + step["content_normalized"] = _normalize_manual_text(step.get("content")) + + related = execute_query( + """ + SELECT rm.slug, rm.title, rm.summary, rm.module + FROM manual_relations mr + JOIN manual_articles rm ON rm.id = mr.related_manual_id + WHERE mr.manual_id = %s + AND rm.deleted_at IS NULL + ORDER BY rm.use_count DESC, rm.updated_at DESC + LIMIT 12 + """, + (article["id"],), + ) or [] + + return templates.TemplateResponse( + "modules/manual/templates/detail.html", + {"request": request, "article": article, "steps": steps, "related": related}, + ) diff --git a/app/modules/manual/templates/admin.html b/app/modules/manual/templates/admin.html new file mode 100644 index 0000000..c8a1d39 --- /dev/null +++ b/app/modules/manual/templates/admin.html @@ -0,0 +1,248 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Manual Admin - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Manual Admin

+
Opret og redigér manualartikler (MVP editor)
+
+ Se manualer +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
Preview
+
+
+
+ +
+
+
Seneste manualer
+
+ {% for article in articles %} +
+
{{ article.title }}
+
{{ article.module }} • {{ article.difficulty }}
+
+ Åbn + +
+
+ {% else %} +
Ingen manualer endnu.
+ {% endfor %} +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/modules/manual/templates/detail.html b/app/modules/manual/templates/detail.html new file mode 100644 index 0000000..ee8502e --- /dev/null +++ b/app/modules/manual/templates/detail.html @@ -0,0 +1,136 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}{% if article %}{{ article.title }} - Manual{% else %}Manual ikke fundet{% endif %} - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ {% if article %} +
+
+ Tilbage til manualer +

{{ article.title }}

+
{{ article.summary or 'Ingen kort beskrivelse.' }}
+
+
+
{{ article.difficulty }}
+
{{ article.use_count or 0 }} visninger
+
+
+ +
+
+ Modul: + {{ article.module|title }} + Åbn i kontekst +
+
+ {% for tag in article.tags or [] %} + #{{ tag }} + {% endfor %} +
+
+ +
+
Guide
+ {% set guide_text = article.content_normalized if article.content_normalized is defined and article.content_normalized else article.content %} +
{{ guide_text | default('', true) | e | replace('<br>', '\n') | replace('<br/>', '\n') | replace('<br />', '\n') | replace('\\n', '\n') }}
+
+ +
+
Step-by-step
+ {% if steps %} +
+ {% for step in steps %} +
+
Step {{ step.step_number }}: {{ step.title }}
+ {% set step_text = step.content_normalized if step.content_normalized is defined and step.content_normalized else step.content %} +
{{ step_text | default('', true) | e | replace('<br>', '\n') | replace('<br/>', '\n') | replace('<br />', '\n') | replace('\\n', '\n') }}
+ {% if step.image_url or step.video_url %} +
+ {% if step.image_url %} + Step billede + {% endif %} + {% if step.video_url %} + + {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
Ingen steps endnu.
+ {% endif %} +
+ +
+
Relaterede guides
+ {% if related %} +
+ {% for item in related %} +
+
+
+
{{ item.title }}
+
{{ item.summary or 'Relateret guide' }}
+ Åbn +
+
+
+ {% endfor %} +
+ {% else %} +
Ingen relaterede guider fundet.
+ {% endif %} +
+ {% else %} +
+
+ +

Manual ikke fundet

+ Tilbage til manualer +
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/manual/templates/list.html b/app/modules/manual/templates/list.html new file mode 100644 index 0000000..739df9e --- /dev/null +++ b/app/modules/manual/templates/list.html @@ -0,0 +1,131 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Manualer - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Manualer

+
Kontekstuel og søgbar hjælp til alle moduler
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ {% if articles %} + {% for article in articles %} +
+
+
+
{{ article.title }}
+ {{ article.difficulty }} +
+
+ {{ article.module|title }} + + {{ article.use_count or 0 }} +
+

{{ article.summary or 'Ingen introduktion endnu.' }}

+
+ {% for tag in article.tags or [] %} + #{{ tag }} + {% endfor %} +
+ + Åbn guide + +
+
+ {% endfor %} + {% else %} +
+
+
+ +
Ingen manualer matcher filteret.
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 67762d2..9a8379a 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -170,25 +170,98 @@ def _validate_customer_id(customer_id: Optional[int], field_name: str = "custome raise HTTPException(status_code=400, detail=f"Invalid {field_name}") -def _activate_waiting_cases_by_status(trigger_case_id: int, status_value: Optional[str]) -> None: +def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]: + if value is None: + return None + + raw_items: List[str] = [] + if isinstance(value, (list, tuple, set)): + raw_items = [str(v or "") for v in value] + else: + raw_items = re.split(r"[,;\n]", str(value or "")) + + cleaned: List[str] = [] + seen = set() + for item in raw_items: + token = str(item or "").strip() + if not token: + continue + key = token.lower() + if key in seen: + continue + seen.add(key) + cleaned.append(token) + + if not cleaned: + return None + return ", ".join(cleaned) + + +def _deferred_status_matches( + deferred_until_status: Optional[str], + previous_status: Optional[str], + new_status: Optional[str], +) -> bool: + normalized_new = str(new_status or "").strip().lower() + normalized_prev = str(previous_status or "").strip().lower() + if not normalized_new: + return False + + status_values = [ + str(part or "").strip().lower() + for part in re.split(r"[,;\n]", str(deferred_until_status or "")) + if str(part or "").strip() + ] + if not status_values: + return False + + if "__any_change__" in status_values and normalized_prev and normalized_prev != normalized_new: + return True + + return normalized_new in status_values + + +def _activate_waiting_cases_by_status( + trigger_case_id: int, + previous_status: Optional[str], + new_status: Optional[str], +) -> None: """Set start_date for waiting cases when their status dependency is met.""" - normalized_status = str(status_value or "").strip() + normalized_status = str(new_status or "").strip() if not normalized_status: return try: - updated_rows = execute_query( + waiting_rows = execute_query( """ - UPDATE sag_sager - SET start_date = NOW(), updated_at = NOW() + SELECT id, deferred_until_status + FROM sag_sager WHERE deleted_at IS NULL AND deferred_until_case_id = %s AND deferred_until_status IS NOT NULL AND start_date IS NULL - AND LOWER(TRIM(deferred_until_status)) = LOWER(TRIM(%s)) + """, + (trigger_case_id,), + ) or [] + + matching_ids = [ + int(row["id"]) + for row in waiting_rows + if _deferred_status_matches(row.get("deferred_until_status"), previous_status, normalized_status) + ] + + if not matching_ids: + return + + placeholders = ",".join(["%s"] * len(matching_ids)) + updated_rows = execute_query( + f""" + UPDATE sag_sager + SET start_date = NOW(), updated_at = NOW() + WHERE id IN ({placeholders}) RETURNING id """, - (trigger_case_id, normalized_status), + tuple(matching_ids), ) or [] if updated_rows: @@ -1018,6 +1091,8 @@ async def update_sag(sag_id: int, updates: dict): updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date") if "deferred_until" in updates: updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until") + if "deferred_until_status" in updates: + updates["deferred_until_status"] = _normalize_deferred_statuses(updates.get("deferred_until_status")) if "priority" in updates: updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal") if "ansvarlig_bruger_id" in updates: @@ -1066,7 +1141,7 @@ async def update_sag(sag_id: int, updates: dict): if "status" in updates: new_status = str((result[0] or {}).get("status") or "").strip().lower() if new_status and new_status != previous_status: - _activate_waiting_cases_by_status(sag_id, new_status) + _activate_waiting_cases_by_status(sag_id, previous_status, new_status) logger.info("✅ Case updated: %s", sag_id) return result[0] raise HTTPException(status_code=500, detail="Failed to update case") diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index ead3d4e..82af0d6 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -1679,19 +1679,20 @@ .case-tabs-topbar { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0.6rem; + gap: 0.7rem; + align-items: start; background: var(--bg-card); border: 1px solid rgba(0,0,0,0.08); - border-radius: 10px; - padding: 0.7rem; + border-radius: 12px; + padding: 0.75rem; margin-bottom: 0.75rem; } .case-tabs-topbar.topbar-primary { grid-template-columns: 105px minmax(170px, 1.1fr) minmax(170px, 1.1fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr); - background: linear-gradient(135deg, rgba(15,76,117,0.12), rgba(15,76,117,0.04)); - border: 1px solid rgba(15,76,117,0.25); - box-shadow: 0 4px 16px rgba(15,76,117,0.12); + background: linear-gradient(140deg, rgba(15,76,117,0.11), rgba(15,76,117,0.03)); + border: 1px solid rgba(15,76,117,0.22); + box-shadow: 0 3px 12px rgba(15,76,117,0.1); margin-bottom: 1rem; } @@ -1710,7 +1711,7 @@ .topbar-primary .case-tabs-topbar-label { color: color-mix(in srgb, var(--accent) 75%, #2f3a45); opacity: 0.95; - font-size: 0.58rem; + font-size: 0.62rem; letter-spacing: 0.1em; } @@ -1841,15 +1842,21 @@ } .case-tabs-topbar.topbar-secondary { - grid-template-columns: repeat(8, minmax(145px, 1fr)); + grid-template-columns: repeat(8, minmax(150px, 1fr)); } .case-tabs-topbar-item { background: color-mix(in srgb, var(--accent) 3%, var(--bg-card)); border: 1px solid rgba(0,0,0,0.06); - border-radius: 8px; - padding: 0.55rem 0.65rem; + border-radius: 10px; + padding: 0.58rem 0.68rem; min-width: 0; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; + } + + .case-tabs-topbar-item:hover { + border-color: rgba(15, 76, 117, 0.28); + box-shadow: 0 2px 8px rgba(15, 76, 117, 0.08); } .case-tabs-topbar-label { @@ -1909,46 +1916,6 @@ display: none; } - .topbar-secondary .case-tabs-topbar-item.field-add { - background: #eaf8ee; - border-color: #cbe9d4; - } - - .topbar-secondary .case-tabs-topbar-item.field-type { - background: #e7f3ff; - border-color: #c7e2ff; - } - - .topbar-secondary .case-tabs-topbar-item.field-created { - background: #eefaf1; - border-color: #d4f0dc; - } - - .topbar-secondary .case-tabs-topbar-item.field-priority { - background: #f2ecff; - border-color: #ddd0ff; - } - - .topbar-secondary .case-tabs-topbar-item.field-start { - background: #fff8e8; - border-color: #f4e4b6; - } - - .topbar-secondary .case-tabs-topbar-item.field-start-before { - background: #fff2e8; - border-color: #f6d9c1; - } - - .topbar-secondary .case-tabs-topbar-item.field-deadline { - background: #ffeaea; - border-color: #f6c5c5; - } - - .topbar-secondary .case-tabs-topbar-item.field-anydesk { - background: #eaf3ff; - border-color: #c8ddff; - } - .topbar-secondary-action { border: 1px solid rgba(0,0,0,0.12); background: rgba(255,255,255,0.72); @@ -2002,10 +1969,31 @@ .topbar-deferred-shortcuts { display: flex; gap: 0.35rem; - margin-top: 0.45rem; + margin-top: 0.35rem; flex-wrap: wrap; } + .topbar-deferred-current { + margin-top: 0.4rem; + padding: 0.3rem 0.45rem; + border-radius: 8px; + font-size: 0.72rem; + line-height: 1.25; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.52); + border: 1px dashed rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + gap: 0.28rem; + } + + .topbar-deferred-current.is-set { + color: #0f4c75; + border-style: solid; + border-color: rgba(15, 76, 117, 0.28); + background: rgba(15, 76, 117, 0.09); + } + .topbar-mini-trigger { border: 1px solid rgba(0,0,0,0.14); background: rgba(255,255,255,0.75); @@ -2014,7 +2002,8 @@ font-size: 0.7rem; font-weight: 700; line-height: 1; - padding: 0.35rem 0.55rem; + padding: 0.34rem 0.58rem; + min-height: 30px; letter-spacing: 0.01em; } @@ -2055,6 +2044,18 @@ border-color: rgba(230, 190, 90, 0.35); } + [data-bs-theme="dark"] .topbar-deferred-current { + color: rgba(236, 242, 255, 0.82); + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.16); + } + + [data-bs-theme="dark"] .topbar-deferred-current.is-set { + color: #9dd7ff; + border-color: rgba(108, 188, 255, 0.42); + background: rgba(19, 100, 154, 0.24); + } + [data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before { background: rgba(150, 90, 30, 0.24); border-color: rgba(230, 160, 90, 0.35); @@ -2282,6 +2283,10 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .case-tabs-topbar.topbar-secondary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .topbar-primary .case-tabs-topbar-item { border-left: none; border-top: 1px solid rgba(15,76,117,0.2); @@ -2296,6 +2301,10 @@ .case-tabs-topbar { grid-template-columns: 1fr; } + + .case-tabs-topbar.topbar-secondary { + grid-template-columns: 1fr; + } } {% endblock %} @@ -2402,6 +2411,15 @@ +
+ + + + +
+
+ +
Start senest
@@ -2419,6 +2437,25 @@
+ {% set deferred_case_ns = namespace(title='') %} + {% if case.deferred_until_case_id %} + {% for rc in related_case_options %} + {% if rc.id == case.deferred_until_case_id %} + {% set deferred_case_ns.title = rc.titel %} + {% endif %} + {% endfor %} + {% endif %} +
+ {% if case.deferred_until_case_id and case.deferred_until_status %} + + + Trigger: #{{ case.deferred_until_case_id }}{% if deferred_case_ns.title %} {{ deferred_case_ns.title }}{% endif %} → {{ (case.deferred_until_status or '')|replace('__any_change__', 'Statusskifte') }} + + {% else %} + + Ingen status-trigger valgt + {% endif %} +
Deadline dato
@@ -3366,17 +3403,22 @@ {% endfor %} - + {% for st in status_options %} - {% endfor %} +
Du kan vælge flere statuser (Cmd/Ctrl + klik).
+
+ + +