Compare commits
2 Commits
1f834160ca
...
ee8c517acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee8c517acc | ||
|
|
807c68679e |
0
app/modules/manual/__init__.py
Normal file
0
app/modules/manual/__init__.py
Normal file
0
app/modules/manual/backend/__init__.py
Normal file
0
app/modules/manual/backend/__init__.py
Normal file
414
app/modules/manual/backend/router.py
Normal file
414
app/modules/manual/backend/router.py
Normal file
@ -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}
|
||||
0
app/modules/manual/frontend/__init__.py
Normal file
0
app/modules/manual/frontend/__init__.py
Normal file
174
app/modules/manual/frontend/views.py
Normal file
174
app/modules/manual/frontend/views.py
Normal file
@ -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)<br\s*/?>", "\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},
|
||||
)
|
||||
248
app/modules/manual/templates/admin.html
Normal file
248
app/modules/manual/templates/admin.html
Normal file
@ -0,0 +1,248 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Manual Admin - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.editor-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.preview-box {
|
||||
min-height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed rgba(0,0,0,0.2);
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4 admin-shell">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1"><i class="bi bi-sliders me-2"></i>Manual Admin</h2>
|
||||
<div class="text-muted">Opret og redigér manualartikler (MVP editor)</div>
|
||||
</div>
|
||||
<a href="/manual" class="btn btn-outline-primary"><i class="bi bi-journal-richtext me-1"></i>Se manualer</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="editor-card p-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label">Titel</label>
|
||||
<input id="title" class="form-control" placeholder="Hvordan opretter jeg en sag?">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">Sværhedsgrad</label>
|
||||
<select id="difficulty" class="form-select">
|
||||
<option value="beginner">beginner</option>
|
||||
<option value="advanced">advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">Modul</label>
|
||||
<input id="module" class="form-control" placeholder="sag">
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label">Tags (kommasepareret)</label>
|
||||
<input id="tags" class="form-control" placeholder="sag, ticket, opgave">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Kort intro</label>
|
||||
<textarea id="summary" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Markdown indhold</label>
|
||||
<textarea id="content" class="form-control mono" rows="10" oninput="renderPreview()"></textarea>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Steps JSON</label>
|
||||
<textarea id="steps" class="form-control mono" rows="8">[]</textarea>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Relationer JSON</label>
|
||||
<textarea id="relations" class="form-control mono" rows="8">[]</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button id="saveBtn" class="btn btn-primary" onclick="createManual()">
|
||||
<i class="bi bi-save me-1"></i>Gem manual
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="resetForm()">Nulstil</button>
|
||||
</div>
|
||||
<div id="status" class="small mt-2 text-muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="editor-card p-3 mt-3">
|
||||
<h5><i class="bi bi-eye me-2"></i>Preview</h5>
|
||||
<div id="preview" class="preview-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="editor-card p-3">
|
||||
<h5><i class="bi bi-list-check me-2"></i>Seneste manualer</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for article in articles %}
|
||||
<div class="list-group-item">
|
||||
<div class="fw-semibold">{{ article.title }}</div>
|
||||
<div class="small text-muted mb-2">{{ article.module }} • {{ article.difficulty }}</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-primary" href="/manual/{{ article.slug }}">Åbn</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadManual('{{ article.slug }}')">Rediger</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">Ingen manualer endnu.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let editingId = null;
|
||||
|
||||
function normalizeEditorText(value) {
|
||||
return String(value || '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.innerText = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const value = document.getElementById('content').value || '';
|
||||
const html = escapeHtml(value).replace(/\n/g, '<br>');
|
||||
document.getElementById('preview').innerHTML = html;
|
||||
}
|
||||
|
||||
function parseJsonField(id) {
|
||||
const raw = document.getElementById(id).value || '[]';
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
throw new Error(`Ugyldig JSON i feltet ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createManual() {
|
||||
const status = document.getElementById('status');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
status.textContent = 'Gemmer...';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: document.getElementById('title').value.trim(),
|
||||
module: document.getElementById('module').value.trim(),
|
||||
difficulty: document.getElementById('difficulty').value,
|
||||
summary: document.getElementById('summary').value.trim(),
|
||||
content: document.getElementById('content').value,
|
||||
tags: (document.getElementById('tags').value || '').split(',').map(t => t.trim()).filter(Boolean),
|
||||
steps: parseJsonField('steps'),
|
||||
relations: parseJsonField('relations')
|
||||
};
|
||||
|
||||
if (!payload.title || !payload.module || !payload.content) {
|
||||
throw new Error('Titel, modul og indhold er påkrævet.');
|
||||
}
|
||||
|
||||
const endpoint = editingId ? `/api/v1/manual/${editingId}` : '/api/v1/manual';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Kunne ikke gemme manual.');
|
||||
}
|
||||
|
||||
status.textContent = (editingId ? 'Manual opdateret: ' : 'Manual gemt: ') + (data.slug || data.title);
|
||||
saveBtn.innerHTML = '<i class="bi bi-save me-1"></i>Gem manual';
|
||||
window.setTimeout(() => {
|
||||
window.location.href = '/manual/' + data.slug;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
status.textContent = 'Fejl: ' + (error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId = null;
|
||||
document.getElementById('title').value = '';
|
||||
document.getElementById('module').value = '';
|
||||
document.getElementById('summary').value = '';
|
||||
document.getElementById('content').value = '';
|
||||
document.getElementById('tags').value = '';
|
||||
document.getElementById('steps').value = '[]';
|
||||
document.getElementById('relations').value = '[]';
|
||||
document.getElementById('status').textContent = '';
|
||||
document.getElementById('saveBtn').innerHTML = '<i class="bi bi-save me-1"></i>Gem manual';
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
async function loadManual(slug) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = 'Henter manual...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/manual/${encodeURIComponent(slug)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Kunne ikke hente manual.');
|
||||
}
|
||||
|
||||
editingId = data.id;
|
||||
document.getElementById('title').value = data.title || '';
|
||||
document.getElementById('module').value = data.module || '';
|
||||
document.getElementById('summary').value = normalizeEditorText(data.summary);
|
||||
document.getElementById('content').value = normalizeEditorText(data.content);
|
||||
document.getElementById('difficulty').value = data.difficulty || 'beginner';
|
||||
document.getElementById('tags').value = (data.tags || []).join(', ');
|
||||
const normalizedSteps = (data.steps || []).map((step) => ({
|
||||
...step,
|
||||
content: normalizeEditorText(step.content)
|
||||
}));
|
||||
document.getElementById('steps').value = JSON.stringify(normalizedSteps, null, 2);
|
||||
document.getElementById('relations').value = JSON.stringify(data.relations || [], null, 2);
|
||||
document.getElementById('saveBtn').innerHTML = '<i class="bi bi-pencil-square me-1"></i>Opdater manual';
|
||||
renderPreview();
|
||||
status.textContent = 'Redigerer: ' + (data.title || slug);
|
||||
} catch (error) {
|
||||
status.textContent = 'Fejl: ' + (error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
renderPreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
136
app/modules/manual/templates/detail.html
Normal file
136
app/modules/manual/templates/detail.html
Normal file
@ -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 %}
|
||||
<style>
|
||||
.manual-shell {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.manual-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.step-card {
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
.step-media img,
|
||||
.step-media video {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4 manual-shell">
|
||||
{% if article %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<a href="/manual" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Tilbage til manualer</a>
|
||||
<h2 class="mt-2 mb-1">{{ article.title }}</h2>
|
||||
<div class="text-muted">{{ article.summary or 'Ingen kort beskrivelse.' }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div><span class="badge bg-secondary">{{ article.difficulty }}</span></div>
|
||||
<div class="small text-muted mt-1"><i class="bi bi-eye me-1"></i>{{ article.use_count or 0 }} visninger</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-section p-3 mb-3">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
||||
<span class="fw-semibold"><i class="bi bi-grid me-1"></i>Modul:</span>
|
||||
<span class="badge text-bg-light">{{ article.module|title }}</span>
|
||||
<a href="/manual?module={{ article.module }}" class="btn btn-sm btn-outline-primary ms-2">Åbn i kontekst</a>
|
||||
</div>
|
||||
<div>
|
||||
{% for tag in article.tags or [] %}
|
||||
<span class="tag-chip">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-section p-3 mb-3">
|
||||
<h5 class="mb-2"><i class="bi bi-journal-text me-2"></i>Guide</h5>
|
||||
{% set guide_text = article.content_normalized if article.content_normalized is defined and article.content_normalized else article.content %}
|
||||
<div class="text-body" style="white-space: pre-line; line-height: 1.6;">{{ guide_text | default('', true) | e | replace('<br>', '\n') | replace('<br/>', '\n') | replace('<br />', '\n') | replace('\\n', '\n') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-section p-3 mb-3">
|
||||
<h5 class="mb-3"><i class="bi bi-list-ol me-2"></i>Step-by-step</h5>
|
||||
{% if steps %}
|
||||
<div class="d-grid gap-2">
|
||||
{% for step in steps %}
|
||||
<div class="step-card p-3">
|
||||
<div class="fw-semibold mb-1">Step {{ step.step_number }}: {{ step.title }}</div>
|
||||
{% set step_text = step.content_normalized if step.content_normalized is defined and step.content_normalized else step.content %}
|
||||
<div class="text-body" style="white-space: pre-line;">{{ step_text | default('', true) | e | replace('<br>', '\n') | replace('<br/>', '\n') | replace('<br />', '\n') | replace('\\n', '\n') }}</div>
|
||||
{% if step.image_url or step.video_url %}
|
||||
<div class="step-media mt-2">
|
||||
{% if step.image_url %}
|
||||
<img src="{{ step.image_url }}" alt="Step billede" loading="lazy">
|
||||
{% endif %}
|
||||
{% if step.video_url %}
|
||||
<video controls preload="none">
|
||||
<source src="{{ step.video_url }}">
|
||||
</video>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">Ingen steps endnu.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="manual-section p-3">
|
||||
<h5 class="mb-3"><i class="bi bi-link-45deg me-2"></i>Relaterede guides</h5>
|
||||
{% if related %}
|
||||
<div class="row g-2">
|
||||
{% for item in related %}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card border-0" style="background: var(--accent-light);">
|
||||
<div class="card-body">
|
||||
<div class="fw-semibold">{{ item.title }}</div>
|
||||
<div class="small text-muted mb-2">{{ item.summary or 'Relateret guide' }}</div>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/manual/{{ item.slug }}">Åbn</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">Ingen relaterede guider fundet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-journal-x" style="font-size: 2rem; color: var(--text-secondary);"></i>
|
||||
<h4 class="mt-3">Manual ikke fundet</h4>
|
||||
<a href="/manual" class="btn btn-primary mt-2">Tilbage til manualer</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
131
app/modules/manual/templates/list.html
Normal file
131
app/modules/manual/templates/list.html
Normal file
@ -0,0 +1,131 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Manualer - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.manual-header {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
padding: 1rem;
|
||||
}
|
||||
.manual-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 12px;
|
||||
height: 100%;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.manual-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
.manual-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1"><i class="bi bi-journal-richtext me-2"></i>Manualer</h2>
|
||||
<div class="text-muted">Kontekstuel og søgbar hjælp til alle moduler</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/manual/admin" class="btn btn-outline-primary">
|
||||
<i class="bi bi-gear me-1"></i>Admin
|
||||
</a>
|
||||
<a href="/manual?module={{ filters.module }}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-header mb-3">
|
||||
<form method="get" class="row g-2">
|
||||
<div class="col-12 col-lg-4">
|
||||
<input type="text" class="form-control" name="search" value="{{ filters.search }}" placeholder="Søg i titel, resume og indhold">
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<select class="form-select" name="module">
|
||||
<option value="">Alle moduler</option>
|
||||
{% for m in available_modules %}
|
||||
<option value="{{ m.module }}" {% if filters.module == m.module %}selected{% endif %}>{{ m.module|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<select class="form-select" name="difficulty">
|
||||
<option value="">Alle niveauer</option>
|
||||
<option value="beginner" {% if filters.difficulty == 'beginner' %}selected{% endif %}>Beginner</option>
|
||||
<option value="advanced" {% if filters.difficulty == 'advanced' %}selected{% endif %}>Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-8 col-lg-3">
|
||||
<select class="form-select" name="tag">
|
||||
<option value="">Alle tags</option>
|
||||
{% for t in available_tags %}
|
||||
<option value="{{ t }}" {% if filters.tag == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4 col-lg-1 d-grid">
|
||||
<button class="btn btn-primary" type="submit"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% if articles %}
|
||||
{% for article in articles %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="manual-card p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="mb-0">{{ article.title }}</h5>
|
||||
<span class="badge bg-secondary">{{ article.difficulty }}</span>
|
||||
</div>
|
||||
<div class="manual-meta mb-2">
|
||||
<i class="bi bi-grid me-1"></i>{{ article.module|title }}
|
||||
<span class="mx-2">•</span>
|
||||
<i class="bi bi-eye me-1"></i>{{ article.use_count or 0 }}
|
||||
</div>
|
||||
<p class="text-muted mb-2">{{ article.summary or 'Ingen introduktion endnu.' }}</p>
|
||||
<div class="mb-3">
|
||||
{% for tag in article.tags or [] %}
|
||||
<span class="tag-chip">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/manual/{{ article.slug }}" class="btn btn-sm btn-outline-primary">
|
||||
Åbn guide <i class="bi bi-arrow-right-short"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="card border-0">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-search" style="font-size: 2rem; color: var(--text-secondary);"></i>
|
||||
<div class="mt-2">Ingen manualer matcher filteret.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -169,6 +169,116 @@ def _validate_customer_id(customer_id: Optional[int], field_name: str = "custome
|
||||
if not exists:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
||||
|
||||
|
||||
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(new_status or "").strip()
|
||||
if not normalized_status:
|
||||
return
|
||||
|
||||
try:
|
||||
waiting_rows = execute_query(
|
||||
"""
|
||||
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
|
||||
""",
|
||||
(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
|
||||
""",
|
||||
tuple(matching_ids),
|
||||
) or []
|
||||
|
||||
if updated_rows:
|
||||
logger.info(
|
||||
"✅ Activated %s waiting case(s) from trigger case %s with status '%s'",
|
||||
len(updated_rows),
|
||||
trigger_case_id,
|
||||
normalized_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"⚠️ Could not activate waiting cases for trigger case %s and status '%s': %s",
|
||||
trigger_case_id,
|
||||
normalized_status,
|
||||
exc,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUICKCREATE AI ANALYSIS
|
||||
# ============================================================================
|
||||
@ -577,6 +687,7 @@ async def list_sager(
|
||||
|
||||
if not include_deferred:
|
||||
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
|
||||
query += " AND (start_date IS NULL OR start_date <= NOW())"
|
||||
|
||||
if status:
|
||||
query += " AND s.status = %s"
|
||||
@ -717,6 +828,8 @@ async def create_sag(data: dict):
|
||||
logger.info("✅ Case created: %s", result[0]["id"])
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error creating case: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
@ -957,10 +1070,15 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
"""Update a case."""
|
||||
try:
|
||||
# Check if case exists
|
||||
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
||||
check = execute_query(
|
||||
"SELECT id, status FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,),
|
||||
)
|
||||
if not check:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
previous_status = str((check[0] or {}).get("status") or "").strip().lower()
|
||||
|
||||
# Backwards compatibility: frontend sends "type", DB stores "template_key"
|
||||
if "type" in updates and "template_key" not in updates:
|
||||
updates["template_key"] = updates.get("type")
|
||||
@ -973,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:
|
||||
@ -1018,6 +1138,10 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
|
||||
result = execute_query(query, tuple(params))
|
||||
if result:
|
||||
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, previous_status, new_status)
|
||||
logger.info("✅ Case updated: %s", sag_id)
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to update case")
|
||||
@ -1387,6 +1511,74 @@ async def list_case_customers(sag_id: int):
|
||||
logger.error("❌ Error listing case customers: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to list case customers")
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/customer/replace")
|
||||
async def replace_case_customer(sag_id: int, payload: dict):
|
||||
"""Replace the primary customer on a case and keep relations synchronized."""
|
||||
try:
|
||||
existing_case = execute_query_single(
|
||||
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,),
|
||||
)
|
||||
if not existing_case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
customer_id = _coerce_optional_int((payload or {}).get("customer_id"), "customer_id")
|
||||
if customer_id is None:
|
||||
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||
|
||||
_validate_customer_id(customer_id)
|
||||
|
||||
if table_has_column("sag_sager", "customer_id"):
|
||||
execute_query(
|
||||
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
|
||||
(customer_id, sag_id),
|
||||
)
|
||||
|
||||
if _table_exists("sag_kunder"):
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE sag_kunder
|
||||
SET deleted_at = NOW()
|
||||
WHERE sag_id = %s
|
||||
AND customer_id <> %s
|
||||
AND deleted_at IS NULL
|
||||
AND LOWER(COALESCE(role, '')) = 'kunde'
|
||||
""",
|
||||
(sag_id, customer_id),
|
||||
)
|
||||
|
||||
existing_link = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sag_kunder
|
||||
WHERE sag_id = %s
|
||||
AND customer_id = %s
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(sag_id, customer_id),
|
||||
)
|
||||
|
||||
if existing_link:
|
||||
execute_query(
|
||||
"UPDATE sag_kunder SET role = %s WHERE id = %s",
|
||||
("Kunde", existing_link["id"]),
|
||||
)
|
||||
else:
|
||||
execute_query(
|
||||
"INSERT INTO sag_kunder (sag_id, customer_id, role) VALUES (%s, %s, %s)",
|
||||
(sag_id, customer_id, "Kunde"),
|
||||
)
|
||||
|
||||
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
|
||||
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error replacing case customer: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to replace case customer")
|
||||
|
||||
@router.post("/sag/{sag_id}/customers")
|
||||
async def add_case_customer(sag_id: int, data: dict):
|
||||
"""Add a customer to a case."""
|
||||
|
||||
@ -237,6 +237,7 @@ async def sager_liste(
|
||||
query += " OR s.deferred_until <= NOW()"
|
||||
query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
|
||||
query += ")"
|
||||
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
|
||||
|
||||
if status:
|
||||
query += " AND s.status = %s"
|
||||
@ -289,6 +290,10 @@ async def sager_liste(
|
||||
"""
|
||||
fallback_params = []
|
||||
|
||||
if not include_deferred:
|
||||
fallback_query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
|
||||
fallback_query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
|
||||
|
||||
if status:
|
||||
fallback_query += " AND s.status = %s"
|
||||
fallback_params.append(status)
|
||||
@ -498,9 +503,50 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
except Exception as e:
|
||||
logger.error(f"Error building relation tree: {e}")
|
||||
relation_tree = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error building relation tree: {e}")
|
||||
relation_tree = []
|
||||
|
||||
# Fallback: if tree builder fails/returns empty but relations exist, render a minimal flat tree.
|
||||
if not relation_tree and relationer:
|
||||
try:
|
||||
root_node = {
|
||||
"case": {
|
||||
"id": sag.get("id"),
|
||||
"titel": sag.get("titel"),
|
||||
"status": sag.get("status"),
|
||||
"type": sag.get("type"),
|
||||
"template_key": sag.get("template_key"),
|
||||
},
|
||||
"relation_type": None,
|
||||
"relation_id": None,
|
||||
"is_current": True,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
seen_related = set()
|
||||
for rel in relationer or []:
|
||||
source_id = rel.get("kilde_sag_id")
|
||||
target_id = rel.get("målsag_id")
|
||||
related_id = target_id if source_id == sag_id else source_id
|
||||
if not related_id or related_id in seen_related:
|
||||
continue
|
||||
seen_related.add(related_id)
|
||||
|
||||
root_node["children"].append({
|
||||
"case": {
|
||||
"id": related_id,
|
||||
"titel": rel.get("mål_titel") if source_id == sag_id else rel.get("kilde_titel"),
|
||||
"status": None,
|
||||
"type": None,
|
||||
"template_key": None,
|
||||
},
|
||||
"relation_type": rel.get("relationstype") or "Relateret til",
|
||||
"relation_id": rel.get("id"),
|
||||
"is_current": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
relation_tree = [root_node]
|
||||
except Exception as fallback_err:
|
||||
logger.warning("⚠️ Could not build fallback relation tree: %s", fallback_err)
|
||||
|
||||
# Fetch customer info if customer_id exists
|
||||
customer = None
|
||||
|
||||
@ -4,6 +4,11 @@ from app.core.database import execute_query
|
||||
class RelationService:
|
||||
"""Service for handling case relations (Sager)"""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_relation_type(value: Optional[str]) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text or "Relateret til"
|
||||
|
||||
@staticmethod
|
||||
def get_relation_tree(root_id: int) -> List[Dict]:
|
||||
"""
|
||||
@ -33,7 +38,16 @@ class RelationService:
|
||||
|
||||
# 2. Fetch details for these cases
|
||||
placeholders = ','.join(['%s'] * len(tree_ids))
|
||||
tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})"
|
||||
tree_cases_query = f"""
|
||||
SELECT
|
||||
id,
|
||||
titel,
|
||||
status,
|
||||
template_key,
|
||||
COALESCE(template_key, 'ticket') AS type
|
||||
FROM sag_sager
|
||||
WHERE id IN ({placeholders})
|
||||
"""
|
||||
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
|
||||
|
||||
# 3. Fetch all edges between these cases
|
||||
@ -53,7 +67,8 @@ class RelationService:
|
||||
# Helper to normalize relation types
|
||||
# Now that we cleaned DB, we expect standard Danish terms, but good to be safe
|
||||
def get_direction(k, m, rtype):
|
||||
rtype_lower = rtype.lower()
|
||||
normalized_type = RelationService._normalize_relation_type(rtype)
|
||||
rtype_lower = normalized_type.lower()
|
||||
if rtype_lower in ['afledt af', 'derived from']:
|
||||
return m, k # m is parent of k
|
||||
if rtype_lower in ['årsag til', 'cause of']:
|
||||
@ -66,7 +81,8 @@ class RelationService:
|
||||
processed_edges = set()
|
||||
|
||||
for edge in tree_edges:
|
||||
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype']
|
||||
k, m = edge['kilde_sag_id'], edge['målsag_id']
|
||||
rtype = RelationService._normalize_relation_type(edge.get('relationstype'))
|
||||
|
||||
# Dedup edges (bi-directional check)
|
||||
edge_key = tuple(sorted((k,m))) + (rtype,)
|
||||
@ -119,7 +135,7 @@ class RelationService:
|
||||
path_visited.add(cid)
|
||||
|
||||
# Sort children for consistent display
|
||||
children_data = sorted(children_map.get(cid, []), key=lambda x: x['type'])
|
||||
children_data = sorted(children_map.get(cid, []), key=lambda x: str(x.get('type') or '').lower())
|
||||
|
||||
children_nodes = []
|
||||
for child_info in children_data:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -418,8 +418,8 @@
|
||||
<th style="width: 170px;">Gruppe/Level</th>
|
||||
<th style="width: 240px;">Næste todo</th>
|
||||
<th style="width: 120px;">Opret.</th>
|
||||
<th style="width: 120px;">Start arbejde</th>
|
||||
<th style="width: 140px;">Start inden</th>
|
||||
<th style="width: 120px;">Arbejdsstart</th>
|
||||
<th style="width: 140px;">Start senest</th>
|
||||
<th style="width: 120px;">Deadline</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}BMC Hub{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f4c75'/%3E%3Ctext x='32' y='42' text-anchor='middle' font-size='30' font-family='Arial, sans-serif' fill='white'%3EB%3C/text%3E%3C/svg%3E">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<style>
|
||||
@ -256,7 +257,7 @@
|
||||
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@ -313,6 +314,9 @@
|
||||
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
||||
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||
</button>
|
||||
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</button>
|
||||
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
|
||||
<i class="bi bi-moon-fill"></i>
|
||||
</button>
|
||||
@ -546,8 +550,21 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
const reason = event && event.reason;
|
||||
const msg = String((reason && reason.message) || reason || '');
|
||||
const stack = String((reason && reason.stack) || '');
|
||||
const combined = (msg + '\n' + stack).toLowerCase();
|
||||
|
||||
// Known Safari/extension autofill overlay crash; ignore noisy external script rejection.
|
||||
if (combined.includes('bootstrap-autofill-overlay.js') || combined.includes('autocompletetype.includes')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/tag-picker.js?v=2.0"></script>
|
||||
<script src="/static/js/tag-picker.js?v=2.1"></script>
|
||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||
<script src="/static/js/sms.js?v=1.0"></script>
|
||||
@ -582,11 +599,22 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
||||
const searchBubbleBtn = document.getElementById('globalSearchBtn');
|
||||
const contextManualBtn = document.getElementById('contextManualBtn');
|
||||
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
|
||||
const profileModalEl = document.getElementById('profileModal');
|
||||
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
|
||||
const globalSearchInput = document.getElementById('globalSearchInput');
|
||||
|
||||
function getCurrentModuleContext() {
|
||||
const path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.startsWith('/sag')) return 'sag';
|
||||
if (path.startsWith('/hardware')) return 'hardware';
|
||||
if (path.startsWith('/emails')) return 'mail';
|
||||
if (path.startsWith('/ordre')) return 'salg';
|
||||
if (path.startsWith('/customers') || path.startsWith('/contacts')) return 'crm';
|
||||
return '';
|
||||
}
|
||||
|
||||
function openGlobalSearchModal() {
|
||||
searchModal.show();
|
||||
setTimeout(() => {
|
||||
@ -627,6 +655,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (contextManualBtn) {
|
||||
contextManualBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const module = getCurrentModuleContext();
|
||||
if (module) {
|
||||
window.location.href = `/manual?module=${encodeURIComponent(module)}`;
|
||||
return;
|
||||
}
|
||||
window.location.href = '/manual';
|
||||
});
|
||||
}
|
||||
|
||||
// Search input listener with debounce
|
||||
let searchTimeout;
|
||||
if (globalSearchInput) {
|
||||
|
||||
4
main.py
4
main.py
@ -131,6 +131,8 @@ from app.modules.calendar.backend import router as calendar_api
|
||||
from app.modules.calendar.frontend import views as calendar_views
|
||||
from app.modules.orders.backend import router as orders_api
|
||||
from app.modules.orders.frontend import views as orders_views
|
||||
from app.modules.manual.backend import router as manual_api
|
||||
from app.modules.manual.frontend import views as manual_views
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -427,6 +429,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devp
|
||||
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.backend import router as links_api
|
||||
@ -460,6 +463,7 @@ app.include_router(telefoni_views.router, tags=["Frontend"])
|
||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||
app.include_router(orders_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
app.include_router(manual_views.router, tags=["Frontend"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
|
||||
73
migrations/161_manual_module.sql
Normal file
73
migrations/161_manual_module.sql
Normal file
@ -0,0 +1,73 @@
|
||||
-- 161_manual_module.sql
|
||||
-- Manual module: articles, steps and contextual relations
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_articles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
module VARCHAR(80) NOT NULL,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
difficulty VARCHAR(20) NOT NULL DEFAULT 'beginner' CHECK (difficulty IN ('beginner', 'advanced')),
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
manual_id UUID NOT NULL REFERENCES manual_articles(id) ON DELETE CASCADE,
|
||||
step_number INTEGER NOT NULL CHECK (step_number > 0),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
video_url TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (manual_id, step_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_relations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
manual_id UUID NOT NULL REFERENCES manual_articles(id) ON DELETE CASCADE,
|
||||
related_module VARCHAR(80),
|
||||
related_tag VARCHAR(120),
|
||||
related_sag_type VARCHAR(120),
|
||||
related_manual_id UUID REFERENCES manual_articles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_articles_module ON manual_articles(module);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_articles_difficulty ON manual_articles(difficulty);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_articles_deleted_at ON manual_articles(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_articles_use_count ON manual_articles(use_count DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_steps_manual_id_step ON manual_steps(manual_id, step_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_relations_manual_id ON manual_relations(manual_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_relations_related_module ON manual_relations(related_module);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_relations_related_tag ON manual_relations(related_tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_relations_related_sag_type ON manual_relations(related_sag_type);
|
||||
|
||||
-- Keep article updated_at fresh on content changes.
|
||||
CREATE OR REPLACE FUNCTION bump_manual_article_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_manual_articles_updated_at ON manual_articles;
|
||||
CREATE TRIGGER trg_manual_articles_updated_at
|
||||
BEFORE UPDATE ON manual_articles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION bump_manual_article_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_manual_steps_updated_at ON manual_steps;
|
||||
CREATE TRIGGER trg_manual_steps_updated_at
|
||||
BEFORE UPDATE ON manual_steps
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION bump_manual_article_updated_at();
|
||||
164
migrations/162_seed_manual_articles.sql
Normal file
164
migrations/162_seed_manual_articles.sql
Normal file
@ -0,0 +1,164 @@
|
||||
-- 162_seed_manual_articles.sql
|
||||
-- Seed starter manuals for the Manual module (idempotent)
|
||||
|
||||
-- 1) Core manual articles
|
||||
INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
|
||||
VALUES
|
||||
(
|
||||
'Hvordan opretter jeg en sag?',
|
||||
'hvordan-opretter-jeg-en-sag',
|
||||
'# Opret en ny sag\n\nDenne guide viser den hurtigste vej til at oprette en sag i BMC Hub.\n\n## Hurtig version\n1. Klik på plus-ikonet i topbaren.\n2. Vælg sag-oprettelse.\n3. Udfyld titel, kunde og beskrivelse.\n4. Sæt status og ansvarlig.\n5. Gem sagen.\n\n## Gode vaner\n- Brug en præcis titel, så sagen kan findes senere.\n- Tilføj tags med det samme for bedre filtrering.\n- Vælg korrekt prioritet fra start.',
|
||||
'Trin-for-trin guide til at oprette en ny sag korrekt og hurtigt.',
|
||||
'sag',
|
||||
'["sag", "opgave", "ticket", "opret"]'::jsonb,
|
||||
'beginner'
|
||||
),
|
||||
(
|
||||
'Sådan finder og opdaterer du hardware',
|
||||
'saadan-finder-og-opdaterer-du-hardware',
|
||||
'# Find og opdater hardware\n\nGuiden hjælper dig med at finde et hardware-asset og opdatere nøglefelter uden fejl.\n\n## Hvad du bør opdatere\n- Serienummer\n- Ejer/kunde\n- Lokation\n- Status\n\n## Tip\nHvis du ikke finder enheden via navn, så prøv serienummer eller ESET UUID.',
|
||||
'Find, verificer og opdatér hardwaredata i supportflowet.',
|
||||
'hardware',
|
||||
'["hardware", "asset", "enhed", "serial"]'::jsonb,
|
||||
'beginner'
|
||||
),
|
||||
(
|
||||
'Mail: sådan finder du en tråd og følger op',
|
||||
'mail-saadan-finder-du-en-traad-og-foelger-op',
|
||||
'# Mail workflow\n\nBrug denne guide når du skal finde en mailtråd, forstå historik og sende opfølgning.\n\n## Fokus\n- Find korrekt tråd\n- Tjek vedhæftninger\n- Link til sag\n- Send tydelig opfølgning\n\n## Tip\nBrug emnelinje + kunde som første filter ved søgning.',
|
||||
'Praktisk flow for mailbehandling og opfølgning i Hubben.',
|
||||
'mail',
|
||||
'["mail", "email", "traad", "opfoelgning"]'::jsonb,
|
||||
'advanced'
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
summary = EXCLUDED.summary,
|
||||
module = EXCLUDED.module,
|
||||
tags = EXCLUDED.tags,
|
||||
difficulty = EXCLUDED.difficulty,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
deleted_at = NULL;
|
||||
|
||||
-- 2) Steps for "Hvordan opretter jeg en sag?"
|
||||
WITH target AS (
|
||||
SELECT id FROM manual_articles WHERE slug = 'hvordan-opretter-jeg-en-sag' LIMIT 1
|
||||
)
|
||||
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
|
||||
SELECT
|
||||
target.id,
|
||||
s.step_number,
|
||||
s.title,
|
||||
s.content,
|
||||
s.image_url,
|
||||
s.video_url
|
||||
FROM target
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Klik på +', 'Brug plus-knappen i topbaren til at starte oprettelse af ny sag.', NULL, NULL),
|
||||
(2, 'Vælg sag-oprettelse', 'Vælg oprettelsesflow for sag, ikke ordre eller anden type.', NULL, NULL),
|
||||
(3, 'Udfyld kernefelter', 'Angiv titel, kunde, beskrivelse og ansvarlig bruger.', NULL, NULL),
|
||||
(4, 'Sæt status og prioritet', 'Sæt status til fx "åben" og vælg en prioritet der passer.', NULL, NULL),
|
||||
(5, 'Gem sagen', 'Klik gem og kontrollér at sagen vises i listen.', NULL, NULL)
|
||||
) AS s(step_number, title, content, image_url, video_url)
|
||||
ON CONFLICT (manual_id, step_number) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
image_url = EXCLUDED.image_url,
|
||||
video_url = EXCLUDED.video_url,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 3) Steps for hardware manual
|
||||
WITH target AS (
|
||||
SELECT id FROM manual_articles WHERE slug = 'saadan-finder-og-opdaterer-du-hardware' LIMIT 1
|
||||
)
|
||||
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
|
||||
SELECT
|
||||
target.id,
|
||||
s.step_number,
|
||||
s.title,
|
||||
s.content,
|
||||
s.image_url,
|
||||
s.video_url
|
||||
FROM target
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Åbn hardwaremodulet', 'Gå til Support -> Hardware for at se assets.', NULL, NULL),
|
||||
(2, 'Søg enheden frem', 'Søg på serienummer, model eller kundenavn.', NULL, NULL),
|
||||
(3, 'Åbn detaljesiden', 'Klik på enheden og kontroller stamdata.', NULL, NULL),
|
||||
(4, 'Ret felter', 'Opdater fx lokation, ejer og status og gem ændringer.', NULL, NULL)
|
||||
) AS s(step_number, title, content, image_url, video_url)
|
||||
ON CONFLICT (manual_id, step_number) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
image_url = EXCLUDED.image_url,
|
||||
video_url = EXCLUDED.video_url,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 4) Steps for mail manual
|
||||
WITH target AS (
|
||||
SELECT id FROM manual_articles WHERE slug = 'mail-saadan-finder-du-en-traad-og-foelger-op' LIMIT 1
|
||||
)
|
||||
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
|
||||
SELECT
|
||||
target.id,
|
||||
s.step_number,
|
||||
s.title,
|
||||
s.content,
|
||||
s.image_url,
|
||||
s.video_url
|
||||
FROM target
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Åbn emailmodulet', 'Gå til Email i hovedmenuen.', NULL, NULL),
|
||||
(2, 'Søg på tråd', 'Søg på emne, afsender eller kunde.', NULL, NULL),
|
||||
(3, 'Kontrollér historik', 'Læs hele tråden og tjek vedhæftninger.', NULL, NULL),
|
||||
(4, 'Link til sag', 'Link mailtråden til relevant sag hvis den findes.', NULL, NULL),
|
||||
(5, 'Send opfølgning', 'Svar kort og tydeligt med næste handling og tidspunkt.', NULL, NULL)
|
||||
) AS s(step_number, title, content, image_url, video_url)
|
||||
ON CONFLICT (manual_id, step_number) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
image_url = EXCLUDED.image_url,
|
||||
video_url = EXCLUDED.video_url,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 5) Context relations
|
||||
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
|
||||
SELECT m.id, 'sag', 'sag', 'ticket', NULL
|
||||
FROM manual_articles m
|
||||
WHERE m.slug = 'hvordan-opretter-jeg-en-sag'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_relations r
|
||||
WHERE r.manual_id = m.id
|
||||
AND COALESCE(r.related_module, '') = 'sag'
|
||||
AND COALESCE(r.related_tag, '') = 'sag'
|
||||
AND COALESCE(r.related_sag_type, '') = 'ticket'
|
||||
);
|
||||
|
||||
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
|
||||
SELECT m.id, 'hardware', 'hardware', NULL, NULL
|
||||
FROM manual_articles m
|
||||
WHERE m.slug = 'saadan-finder-og-opdaterer-du-hardware'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_relations r
|
||||
WHERE r.manual_id = m.id
|
||||
AND COALESCE(r.related_module, '') = 'hardware'
|
||||
AND COALESCE(r.related_tag, '') = 'hardware'
|
||||
);
|
||||
|
||||
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
|
||||
SELECT m.id, 'mail', 'mail', NULL, NULL
|
||||
FROM manual_articles m
|
||||
WHERE m.slug = 'mail-saadan-finder-du-en-traad-og-foelger-op'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_relations r
|
||||
WHERE r.manual_id = m.id
|
||||
AND COALESCE(r.related_module, '') = 'mail'
|
||||
AND COALESCE(r.related_tag, '') = 'mail'
|
||||
);
|
||||
16
migrations/163_normalize_manual_newlines.sql
Normal file
16
migrations/163_normalize_manual_newlines.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 163_normalize_manual_newlines.sql
|
||||
-- Normalize legacy literal "\\n" sequences in manual text fields to real newlines.
|
||||
|
||||
UPDATE manual_articles
|
||||
SET
|
||||
content = REPLACE(content, E'\\n', E'\n'),
|
||||
summary = REPLACE(COALESCE(summary, ''), E'\\n', E'\n'),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE POSITION(E'\\n' IN content) > 0
|
||||
OR POSITION(E'\\n' IN COALESCE(summary, '')) > 0;
|
||||
|
||||
UPDATE manual_steps
|
||||
SET
|
||||
content = REPLACE(content, E'\\n', E'\n'),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE POSITION(E'\\n' IN content) > 0;
|
||||
163
migrations/164_seed_manual_articles_more.sql
Normal file
163
migrations/164_seed_manual_articles_more.sql
Normal file
@ -0,0 +1,163 @@
|
||||
-- 164_seed_manual_articles_more.sql
|
||||
-- Seed additional manuals for sag/mail/workflow usage (idempotent)
|
||||
|
||||
-- 1) Additional manual articles
|
||||
INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
|
||||
VALUES
|
||||
(
|
||||
'Sådan bruger du tags i sager',
|
||||
'saadan-bruger-du-tags-i-sager',
|
||||
'# Brug tags aktivt i sager\n\nTags gør det nemmere at filtrere, finde og automatisere sager.\n\n## Når du opretter en sag\n- Tilføj mindst ét type-tag\n- Tilføj evt. brand-tag\n- Undgå dubletter og næsten-identiske tags\n\n## Efterfølgende\n- Brug tag-filter i lister\n- Hold tags opdateret når sagen ændrer retning',
|
||||
'Guide til bedre struktur og hurtigere søgning med tags i sag-modulet.',
|
||||
'sag',
|
||||
'["sag", "tags", "filtrering", "workflow"]'::jsonb,
|
||||
'beginner'
|
||||
),
|
||||
(
|
||||
'Sådan linker du mail til sag',
|
||||
'saadan-linker-du-mail-til-sag',
|
||||
'# Link mailtråde til sager\n\nNår mail og sag er koblet, får du bedre historik og hurtigere opfølgning.\n\n## Hvornår skal du linke\n- Når mailen handler om en eksisterende sag\n- Når der opstår ny opgave i tråden\n\n## Fordel\nHele teamet kan se samme kontekst uden at lede i flere moduler.',
|
||||
'Praktisk flow for at koble email-tråde korrekt til sager.',
|
||||
'mail',
|
||||
'["mail", "sag", "link", "email", "ticket"]'::jsonb,
|
||||
'beginner'
|
||||
),
|
||||
(
|
||||
'Reminders og deferred status i sag-modulet',
|
||||
'reminders-og-deferred-status-i-sag-modulet',
|
||||
'# Brug reminders og deferred korrekt\n\nReminders og deferred hjælper med at holde fokus på det rigtige tidspunkt.\n\n## Reminder bruges til\n- Næste handling på bestemt dato\n- Husk-opgaver uden statusskifte\n\n## Deferred bruges til\n- Vent på ekstern part\n- Genåbn eller aktiver ved bestemt status-trigger\n\n## Best practice\nSkriv altid kort hvorfor sagen er deferred, så næste kollega kan tage over.',
|
||||
'Guide til at styre ventende sager med reminders og deferred triggers.',
|
||||
'sag',
|
||||
'["sag", "reminder", "deferred", "status", "opfoelgning"]'::jsonb,
|
||||
'advanced'
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
summary = EXCLUDED.summary,
|
||||
module = EXCLUDED.module,
|
||||
tags = EXCLUDED.tags,
|
||||
difficulty = EXCLUDED.difficulty,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
deleted_at = NULL;
|
||||
|
||||
-- 2) Steps: tags i sager
|
||||
WITH target AS (
|
||||
SELECT id FROM manual_articles WHERE slug = 'saadan-bruger-du-tags-i-sager' LIMIT 1
|
||||
)
|
||||
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
|
||||
SELECT
|
||||
target.id,
|
||||
s.step_number,
|
||||
s.title,
|
||||
s.content,
|
||||
s.image_url,
|
||||
s.video_url
|
||||
FROM target
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Åbn en sag', 'Gå til sagens detaljeside hvor tags kan redigeres.', NULL, NULL),
|
||||
(2, 'Tilføj relevante tags', 'Tilføj type-tag og evt. brand-tag der matcher problemstillingen.', NULL, NULL),
|
||||
(3, 'Undgå støj', 'Fjern overflødige tags så filtrering forbliver præcis.', NULL, NULL),
|
||||
(4, 'Gem og verificér', 'Gem sagen og test filtrering i sag-listen med de nye tags.', NULL, NULL)
|
||||
) AS s(step_number, title, content, image_url, video_url)
|
||||
ON CONFLICT (manual_id, step_number) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
image_url = EXCLUDED.image_url,
|
||||
video_url = EXCLUDED.video_url,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 3) Steps: link mail til sag
|
||||
WITH target AS (
|
||||
SELECT id FROM manual_articles WHERE slug = 'saadan-linker-du-mail-til-sag' LIMIT 1
|
||||
)
|
||||
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
|
||||
SELECT
|
||||
target.id,
|
||||
s.step_number,
|
||||
s.title,
|
||||
s.content,
|
||||
s.image_url,
|
||||
s.video_url
|
||||
FROM target
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Find mailtråden', 'Åbn emailmodulet og søg den relevante tråd frem.', NULL, NULL),
|
||||
(2, 'Vælg korrekt sag', 'Match tråden med den sag, der allerede indeholder konteksten.', NULL, NULL),
|
||||
(3, 'Opret link', 'Link mailen til sagen via den relevante handling i UI.', NULL, NULL),
|
||||
(4, 'Bekræft historik', 'Kontrollér at mailaktivitet nu kan ses fra sagen.', NULL, NULL)
|
||||
) AS s(step_number, title, content, image_url, video_url)
|
||||
ON CONFLICT (manual_id, step_number) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
image_url = EXCLUDED.image_url,
|
||||
video_url = EXCLUDED.video_url,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 4) Steps: reminders/deferred
|
||||
WITH target AS (
|
||||
SELECT id FROM manual_articles WHERE slug = 'reminders-og-deferred-status-i-sag-modulet' LIMIT 1
|
||||
)
|
||||
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
|
||||
SELECT
|
||||
target.id,
|
||||
s.step_number,
|
||||
s.title,
|
||||
s.content,
|
||||
s.image_url,
|
||||
s.video_url
|
||||
FROM target
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
(1, 'Vurder om sagen skal vente', 'Afgør om sagen reelt er blokeret af ekstern handling.', NULL, NULL),
|
||||
(2, 'Sæt deferred/reminder', 'Vælg deferred trigger eller reminder-dato afhængigt af behov.', NULL, NULL),
|
||||
(3, 'Skriv kort begrundelse', 'Notér hvad der ventes på, og hvem der ejer næste skridt.', NULL, NULL),
|
||||
(4, 'Følg op ved trigger', 'Når trigger rammer, genaktiver sagen og fortsæt workflowet.', NULL, NULL)
|
||||
) AS s(step_number, title, content, image_url, video_url)
|
||||
ON CONFLICT (manual_id, step_number) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
image_url = EXCLUDED.image_url,
|
||||
video_url = EXCLUDED.video_url,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 5) Context relations
|
||||
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
|
||||
SELECT m.id, 'sag', 'tags', 'ticket', NULL
|
||||
FROM manual_articles m
|
||||
WHERE m.slug = 'saadan-bruger-du-tags-i-sager'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_relations r
|
||||
WHERE r.manual_id = m.id
|
||||
AND COALESCE(r.related_module, '') = 'sag'
|
||||
AND COALESCE(r.related_tag, '') = 'tags'
|
||||
AND COALESCE(r.related_sag_type, '') = 'ticket'
|
||||
);
|
||||
|
||||
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
|
||||
SELECT m.id, 'mail', 'email', NULL, NULL
|
||||
FROM manual_articles m
|
||||
WHERE m.slug = 'saadan-linker-du-mail-til-sag'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_relations r
|
||||
WHERE r.manual_id = m.id
|
||||
AND COALESCE(r.related_module, '') = 'mail'
|
||||
AND COALESCE(r.related_tag, '') = 'email'
|
||||
);
|
||||
|
||||
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
|
||||
SELECT m.id, 'sag', 'deferred', 'ticket', NULL
|
||||
FROM manual_articles m
|
||||
WHERE m.slug = 'reminders-og-deferred-status-i-sag-modulet'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_relations r
|
||||
WHERE r.manual_id = m.id
|
||||
AND COALESCE(r.related_module, '') = 'sag'
|
||||
AND COALESCE(r.related_tag, '') = 'deferred'
|
||||
AND COALESCE(r.related_sag_type, '') = 'ticket'
|
||||
);
|
||||
@ -9,6 +9,7 @@ class TagPicker {
|
||||
this.searchInput = null;
|
||||
this.resultsContainer = null;
|
||||
this.allTags = [];
|
||||
this.tagGroups = [];
|
||||
this.filteredTags = [];
|
||||
this.selectedIndex = 0;
|
||||
this.onSelectCallback = null;
|
||||
@ -155,7 +156,9 @@ class TagPicker {
|
||||
const response = await fetch('/api/v1/tags?is_active=true');
|
||||
if (!response.ok) throw new Error('Failed to load tags');
|
||||
this.allTags = await response.json();
|
||||
this.tagGroups = await this.loadTagGroups();
|
||||
// Tag groups are optional metadata. Some hubs do not expose the endpoint,
|
||||
// so keep picker resilient by not depending on a second API call.
|
||||
this.tagGroups = [];
|
||||
console.log('🏷️ Loaded tags:', this.allTags.length);
|
||||
this.filteredTags = [...this.allTags];
|
||||
this.renderResults();
|
||||
@ -164,17 +167,6 @@ class TagPicker {
|
||||
}
|
||||
}
|
||||
|
||||
async loadTagGroups() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/tags/groups');
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('🏷️ Error loading tag groups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
filterTags(query) {
|
||||
if (!query.trim()) {
|
||||
this.filteredTags = [...this.allTags];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user