feat(manual): add admin interface for creating and editing manuals
- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations. - Added preview functionality for markdown content. - Created list view for recent manuals with edit and view options. - Developed detail view for individual manuals displaying content, steps, and related guides. - Established database schema for manual articles, steps, and relations with appropriate indexing. - Seeded initial manual articles and steps for core functionalities. - Normalized newline characters in existing manual content. - Added additional manuals and steps for enhanced user guidance.
This commit is contained in:
parent
807c68679e
commit
ee8c517acc
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 %}
|
||||||
@ -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}")
|
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."""
|
"""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:
|
if not normalized_status:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated_rows = execute_query(
|
waiting_rows = execute_query(
|
||||||
"""
|
"""
|
||||||
UPDATE sag_sager
|
SELECT id, deferred_until_status
|
||||||
SET start_date = NOW(), updated_at = NOW()
|
FROM sag_sager
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND deferred_until_case_id = %s
|
AND deferred_until_case_id = %s
|
||||||
AND deferred_until_status IS NOT NULL
|
AND deferred_until_status IS NOT NULL
|
||||||
AND start_date IS 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
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(trigger_case_id, normalized_status),
|
tuple(matching_ids),
|
||||||
) or []
|
) or []
|
||||||
|
|
||||||
if updated_rows:
|
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")
|
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
|
||||||
if "deferred_until" in updates:
|
if "deferred_until" in updates:
|
||||||
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
|
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
|
||||||
|
if "deferred_until_status" in updates:
|
||||||
|
updates["deferred_until_status"] = _normalize_deferred_statuses(updates.get("deferred_until_status"))
|
||||||
if "priority" in updates:
|
if "priority" in updates:
|
||||||
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
|
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
|
||||||
if "ansvarlig_bruger_id" in updates:
|
if "ansvarlig_bruger_id" in updates:
|
||||||
@ -1066,7 +1141,7 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
if "status" in updates:
|
if "status" in updates:
|
||||||
new_status = str((result[0] or {}).get("status") or "").strip().lower()
|
new_status = str((result[0] or {}).get("status") or "").strip().lower()
|
||||||
if new_status and new_status != previous_status:
|
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)
|
logger.info("✅ Case updated: %s", sag_id)
|
||||||
return result[0]
|
return result[0]
|
||||||
raise HTTPException(status_code=500, detail="Failed to update case")
|
raise HTTPException(status_code=500, detail="Failed to update case")
|
||||||
|
|||||||
@ -1679,19 +1679,20 @@
|
|||||||
.case-tabs-topbar {
|
.case-tabs-topbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 0.6rem;
|
gap: 0.7rem;
|
||||||
|
align-items: start;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0,0,0,0.08);
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
padding: 0.7rem;
|
padding: 0.75rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-tabs-topbar.topbar-primary {
|
.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);
|
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));
|
background: linear-gradient(140deg, rgba(15,76,117,0.11), rgba(15,76,117,0.03));
|
||||||
border: 1px solid rgba(15,76,117,0.25);
|
border: 1px solid rgba(15,76,117,0.22);
|
||||||
box-shadow: 0 4px 16px rgba(15,76,117,0.12);
|
box-shadow: 0 3px 12px rgba(15,76,117,0.1);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1710,7 +1711,7 @@
|
|||||||
.topbar-primary .case-tabs-topbar-label {
|
.topbar-primary .case-tabs-topbar-label {
|
||||||
color: color-mix(in srgb, var(--accent) 75%, #2f3a45);
|
color: color-mix(in srgb, var(--accent) 75%, #2f3a45);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
font-size: 0.58rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1841,15 +1842,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.case-tabs-topbar.topbar-secondary {
|
.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 {
|
.case-tabs-topbar-item {
|
||||||
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
|
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
|
||||||
border: 1px solid rgba(0,0,0,0.06);
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.58rem 0.68rem;
|
||||||
min-width: 0;
|
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 {
|
.case-tabs-topbar-label {
|
||||||
@ -1909,46 +1916,6 @@
|
|||||||
display: none;
|
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 {
|
.topbar-secondary-action {
|
||||||
border: 1px solid rgba(0,0,0,0.12);
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
background: rgba(255,255,255,0.72);
|
background: rgba(255,255,255,0.72);
|
||||||
@ -2002,10 +1969,31 @@
|
|||||||
.topbar-deferred-shortcuts {
|
.topbar-deferred-shortcuts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.35rem;
|
||||||
flex-wrap: wrap;
|
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 {
|
.topbar-mini-trigger {
|
||||||
border: 1px solid rgba(0,0,0,0.14);
|
border: 1px solid rgba(0,0,0,0.14);
|
||||||
background: rgba(255,255,255,0.75);
|
background: rgba(255,255,255,0.75);
|
||||||
@ -2014,7 +2002,8 @@
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.35rem 0.55rem;
|
padding: 0.34rem 0.58rem;
|
||||||
|
min-height: 30px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2055,6 +2044,18 @@
|
|||||||
border-color: rgba(230, 190, 90, 0.35);
|
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 {
|
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before {
|
||||||
background: rgba(150, 90, 30, 0.24);
|
background: rgba(150, 90, 30, 0.24);
|
||||||
border-color: rgba(230, 160, 90, 0.35);
|
border-color: rgba(230, 160, 90, 0.35);
|
||||||
@ -2282,6 +2283,10 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.topbar-primary .case-tabs-topbar-item {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid rgba(15,76,117,0.2);
|
border-top: 1px solid rgba(15,76,117,0.2);
|
||||||
@ -2296,6 +2301,10 @@
|
|||||||
.case-tabs-topbar {
|
.case-tabs-topbar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.case-tabs-topbar.topbar-secondary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -2402,6 +2411,15 @@
|
|||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="topbar-deferred-shortcuts">
|
||||||
|
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(0)">I dag</button>
|
||||||
|
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(1)">+1 dag</button>
|
||||||
|
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(7)">+1 uge</button>
|
||||||
|
<button type="button" class="topbar-mini-trigger" onclick="openStartDateModal()">Flere valg</button>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-deferred-shortcuts">
|
||||||
|
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModal()">Trigger</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-start-before">
|
<div class="case-tabs-topbar-item field-start-before">
|
||||||
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
|
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
|
||||||
@ -2419,6 +2437,25 @@
|
|||||||
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button>
|
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button>
|
||||||
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button>
|
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
<div class="topbar-deferred-current {% if case.deferred_until_case_id and case.deferred_until_status %}is-set{% endif %}" id="topbarDeferredStatusIndicator">
|
||||||
|
{% if case.deferred_until_case_id and case.deferred_until_status %}
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
<span>
|
||||||
|
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') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-dot"></i>
|
||||||
|
<span>Ingen status-trigger valgt</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-tabs-topbar-item field-deadline">
|
<div class="case-tabs-topbar-item field-deadline">
|
||||||
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
|
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
|
||||||
@ -3366,17 +3403,22 @@
|
|||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select class="form-select form-select-sm mt-2" id="deferredStatusSelect">
|
{% set deferred_status_values = (case.deferred_until_status or '').lower().replace(', ', ',').split(',') %}
|
||||||
<option value="">Vælg status</option>
|
<select class="form-select form-select-sm mt-2" id="deferredStatusSelect" multiple size="6">
|
||||||
|
<option value="__any_change__" {% if '__any_change__' in deferred_status_values %}selected{% endif %}>
|
||||||
|
Ved statusskifte fra nuværende
|
||||||
|
</option>
|
||||||
{% for st in status_options %}
|
{% for st in status_options %}
|
||||||
<option value="{{ st }}" {% if case.deferred_until_status == st %}selected{% endif %}>
|
<option value="{{ st }}" {% if st|lower in deferred_status_values %}selected{% endif %}>
|
||||||
{{ st }}
|
{{ st }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<div class="form-text mt-1">Du kan vælge flere statuser (Cmd/Ctrl + klik).</div>
|
||||||
<div class="defer-controls mt-2">
|
<div class="defer-controls mt-2">
|
||||||
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredCloseTrigger()">Når valgt sag lukkes</button>
|
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredCloseTrigger()">Når valgt sag lukkes</button>
|
||||||
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredResolvedTrigger()">Når valgt sag er løst</button>
|
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredResolvedTrigger()">Når valgt sag er løst</button>
|
||||||
|
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredAnyStatusChangeTrigger()">Ved statusskifte</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@ -3388,6 +3430,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Date Modal -->
|
||||||
|
<div class="modal fade" id="startDateModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Arbejdsstart</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label">Dato</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
id="startDateModalInput"
|
||||||
|
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
|
||||||
|
/>
|
||||||
|
<div class="defer-controls mt-2">
|
||||||
|
<button class="btn btn-outline-primary" type="button" onclick="setStartDateModalToday()">I dag</button>
|
||||||
|
<button class="btn btn-outline-primary" type="button" onclick="shiftStartDateDays(1)">+1 dag</button>
|
||||||
|
<button class="btn btn-outline-primary" type="button" onclick="shiftStartDateDays(7)">+1 uge</button>
|
||||||
|
<button class="btn btn-outline-primary" type="button" onclick="shiftStartDateMonths(1)">+1 mnd</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="clearStartDateFromModal()">Ryd</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveStartDateFromModal()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Module Control Modal -->
|
<!-- Module Control Modal -->
|
||||||
<div class="modal fade" id="moduleControlModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="moduleControlModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@ -9932,6 +10006,28 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeferredSelectedStatuses() {
|
||||||
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
||||||
|
if (!statusSelect) return [];
|
||||||
|
return Array.from(statusSelect.selectedOptions || [])
|
||||||
|
.map((opt) => String(opt.value || '').trim())
|
||||||
|
.filter((value) => value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDeferredSelectedStatuses(values) {
|
||||||
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
||||||
|
if (!statusSelect) return;
|
||||||
|
|
||||||
|
const normalized = new Set((Array.isArray(values) ? values : [])
|
||||||
|
.map((value) => String(value || '').trim().toLowerCase())
|
||||||
|
.filter((value) => value.length > 0));
|
||||||
|
|
||||||
|
Array.from(statusSelect.options || []).forEach((opt) => {
|
||||||
|
const key = String(opt.value || '').trim().toLowerCase();
|
||||||
|
opt.selected = normalized.has(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openDeferredModalWithPresetStatus(statusKey) {
|
function openDeferredModalWithPresetStatus(statusKey) {
|
||||||
const target = String(statusKey || '').trim().toLowerCase();
|
const target = String(statusKey || '').trim().toLowerCase();
|
||||||
const fallback = target === 'lukket' ? 'luk' : target;
|
const fallback = target === 'lukket' ? 'luk' : target;
|
||||||
@ -9946,19 +10042,25 @@
|
|||||||
|
|
||||||
const statusSelect = document.getElementById('deferredStatusSelect');
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
||||||
if (statusSelect) {
|
if (statusSelect) {
|
||||||
statusSelect.value = value;
|
setDeferredSelectedStatuses([value]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateDeferredCaseAndStatus(caseId, status) {
|
async function updateDeferredCaseAndStatus(caseId, statuses) {
|
||||||
try {
|
try {
|
||||||
|
const normalizedStatuses = Array.isArray(statuses)
|
||||||
|
? statuses
|
||||||
|
.map((status) => String(status || '').trim())
|
||||||
|
.filter((status) => status.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
deferred_until_case_id: caseId ? parseInt(caseId, 10) : null,
|
deferred_until_case_id: caseId ? parseInt(caseId, 10) : null,
|
||||||
deferred_until_status: status || null
|
deferred_until_status: normalizedStatuses.length ? normalizedStatuses.join(', ') : null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -9973,8 +10075,7 @@
|
|||||||
|
|
||||||
function setDeferredCaseFromInputs() {
|
function setDeferredCaseFromInputs() {
|
||||||
const caseSelect = document.getElementById('deferredCaseSelect');
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
||||||
const statusSelect = document.getElementById('deferredStatusSelect');
|
updateDeferredCaseAndStatus(caseSelect.value || null, getDeferredSelectedStatuses());
|
||||||
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDeferredStatusTrigger(primaryStatus, fallbackContains, missingMessage) {
|
function applyDeferredStatusTrigger(primaryStatus, fallbackContains, missingMessage) {
|
||||||
@ -9996,7 +10097,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
statusSelect.value = matchValue;
|
setDeferredSelectedStatuses([matchValue]);
|
||||||
saveDeferredAll();
|
saveDeferredAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10008,29 +10109,36 @@
|
|||||||
applyDeferredStatusTrigger('løst', 'løs', 'Status "Løst" findes ikke i listen');
|
applyDeferredStatusTrigger('løst', 'løs', 'Status "Løst" findes ikke i listen');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDeferredAnyStatusChangeTrigger() {
|
||||||
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
||||||
|
if (!caseSelect || !caseSelect.value) {
|
||||||
|
showToast('Vælg først en relateret sag', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeferredSelectedStatuses(['__any_change__']);
|
||||||
|
saveDeferredAll();
|
||||||
|
}
|
||||||
|
|
||||||
function clearDeferredCase() {
|
function clearDeferredCase() {
|
||||||
const caseSelect = document.getElementById('deferredCaseSelect');
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
||||||
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
||||||
caseSelect.value = '';
|
caseSelect.value = '';
|
||||||
statusSelect.value = '';
|
setDeferredSelectedStatuses([]);
|
||||||
updateDeferredCaseAndStatus(null, null);
|
updateDeferredCaseAndStatus(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDeferredAll() {
|
function saveDeferredAll() {
|
||||||
const input = document.getElementById('deferredUntilInput');
|
const input = document.getElementById('deferredUntilInput');
|
||||||
const caseSelect = document.getElementById('deferredCaseSelect');
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
||||||
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
||||||
updateDeferredUntil(input.value || null);
|
updateDeferredUntil(input.value || null);
|
||||||
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
|
updateDeferredCaseAndStatus(caseSelect.value || null, getDeferredSelectedStatuses());
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDeferredAll() {
|
function clearDeferredAll() {
|
||||||
const input = document.getElementById('deferredUntilInput');
|
const input = document.getElementById('deferredUntilInput');
|
||||||
const caseSelect = document.getElementById('deferredCaseSelect');
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
||||||
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
caseSelect.value = '';
|
caseSelect.value = '';
|
||||||
statusSelect.value = '';
|
setDeferredSelectedStatuses([]);
|
||||||
updateDeferredUntil(null);
|
updateDeferredUntil(null);
|
||||||
updateDeferredCaseAndStatus(null, null);
|
updateDeferredCaseAndStatus(null, null);
|
||||||
}
|
}
|
||||||
@ -10233,6 +10341,63 @@
|
|||||||
saveCaseStartDateFromTopbar();
|
saveCaseStartDateFromTopbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStartDateAndSave(offsetDays = 0) {
|
||||||
|
const input = document.getElementById('topbarStartDateInput');
|
||||||
|
if (!input) return;
|
||||||
|
const base = new Date();
|
||||||
|
base.setDate(base.getDate() + Number(offsetDays || 0));
|
||||||
|
input.value = base.toISOString().slice(0, 10);
|
||||||
|
saveCaseStartDateFromTopbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStartDateModal() {
|
||||||
|
const topbarInput = document.getElementById('topbarStartDateInput');
|
||||||
|
const modalInput = document.getElementById('startDateModalInput');
|
||||||
|
if (topbarInput && modalInput) {
|
||||||
|
modalInput.value = topbarInput.value || '';
|
||||||
|
}
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('startDateModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftStartDateDays(days) {
|
||||||
|
const input = document.getElementById('startDateModalInput');
|
||||||
|
if (!input) return;
|
||||||
|
const base = input.value ? new Date(input.value) : new Date();
|
||||||
|
base.setDate(base.getDate() + days);
|
||||||
|
input.value = base.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftStartDateMonths(months) {
|
||||||
|
const input = document.getElementById('startDateModalInput');
|
||||||
|
if (!input) return;
|
||||||
|
const base = input.value ? new Date(input.value) : new Date();
|
||||||
|
base.setMonth(base.getMonth() + months);
|
||||||
|
input.value = base.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStartDateModalToday() {
|
||||||
|
const input = document.getElementById('startDateModalInput');
|
||||||
|
if (!input) return;
|
||||||
|
input.value = new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStartDateFromModal() {
|
||||||
|
const topbarInput = document.getElementById('topbarStartDateInput');
|
||||||
|
const modalInput = document.getElementById('startDateModalInput');
|
||||||
|
if (topbarInput) topbarInput.value = '';
|
||||||
|
if (modalInput) modalInput.value = '';
|
||||||
|
saveCaseStartDateFromTopbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStartDateFromModal() {
|
||||||
|
const topbarInput = document.getElementById('topbarStartDateInput');
|
||||||
|
const modalInput = document.getElementById('startDateModalInput');
|
||||||
|
if (!topbarInput || !modalInput) return;
|
||||||
|
topbarInput.value = modalInput.value || '';
|
||||||
|
saveCaseStartDateFromTopbar();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAssignmentFromTabsBar() {
|
async function saveAssignmentFromTabsBar() {
|
||||||
const topUser = document.getElementById('tabsAssignmentUserSelect');
|
const topUser = document.getElementById('tabsAssignmentUserSelect');
|
||||||
const topGroup = document.getElementById('tabsAssignmentGroupSelect');
|
const topGroup = document.getElementById('tabsAssignmentGroupSelect');
|
||||||
|
|||||||
@ -257,7 +257,7 @@
|
|||||||
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
<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><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="/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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
@ -314,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)">
|
<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>
|
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||||
</button>
|
</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);">
|
<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>
|
<i class="bi bi-moon-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -596,11 +599,22 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
||||||
const searchBubbleBtn = document.getElementById('globalSearchBtn');
|
const searchBubbleBtn = document.getElementById('globalSearchBtn');
|
||||||
|
const contextManualBtn = document.getElementById('contextManualBtn');
|
||||||
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
|
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
|
||||||
const profileModalEl = document.getElementById('profileModal');
|
const profileModalEl = document.getElementById('profileModal');
|
||||||
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
|
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
|
||||||
const globalSearchInput = document.getElementById('globalSearchInput');
|
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() {
|
function openGlobalSearchModal() {
|
||||||
searchModal.show();
|
searchModal.show();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -641,6 +655,18 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Search input listener with debounce
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
if (globalSearchInput) {
|
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.calendar.frontend import views as calendar_views
|
||||||
from app.modules.orders.backend import router as orders_api
|
from app.modules.orders.backend import router as orders_api
|
||||||
from app.modules.orders.frontend import views as orders_views
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
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(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
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(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||||
|
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
|
||||||
|
|
||||||
if settings.LINKS_MODULE_ENABLED:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
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(calendar_views.router, tags=["Frontend"])
|
||||||
app.include_router(orders_views.router, tags=["Frontend"])
|
app.include_router(orders_views.router, tags=["Frontend"])
|
||||||
app.include_router(anydesk_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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.frontend import views as links_views
|
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'
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user