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:
Christian 2026-04-05 21:48:59 +02:00
parent 807c68679e
commit ee8c517acc
16 changed files with 1865 additions and 76 deletions

View File

View File

View 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}

View File

View 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},
)

View 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(/&lt;br\s*\/?&gt;/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 %}

View 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('&lt;br&gt;', '\n') | replace('&lt;br/&gt;', '\n') | replace('&lt;br /&gt;', '\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('&lt;br&gt;', '\n') | replace('&lt;br/&gt;', '\n') | replace('&lt;br /&gt;', '\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 %}

View 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 %}

View File

@ -170,25 +170,98 @@ def _validate_customer_id(customer_id: Optional[int], field_name: str = "custome
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _activate_waiting_cases_by_status(trigger_case_id: int, status_value: Optional[str]) -> None:
def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]:
if value is None:
return None
raw_items: List[str] = []
if isinstance(value, (list, tuple, set)):
raw_items = [str(v or "") for v in value]
else:
raw_items = re.split(r"[,;\n]", str(value or ""))
cleaned: List[str] = []
seen = set()
for item in raw_items:
token = str(item or "").strip()
if not token:
continue
key = token.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(token)
if not cleaned:
return None
return ", ".join(cleaned)
def _deferred_status_matches(
deferred_until_status: Optional[str],
previous_status: Optional[str],
new_status: Optional[str],
) -> bool:
normalized_new = str(new_status or "").strip().lower()
normalized_prev = str(previous_status or "").strip().lower()
if not normalized_new:
return False
status_values = [
str(part or "").strip().lower()
for part in re.split(r"[,;\n]", str(deferred_until_status or ""))
if str(part or "").strip()
]
if not status_values:
return False
if "__any_change__" in status_values and normalized_prev and normalized_prev != normalized_new:
return True
return normalized_new in status_values
def _activate_waiting_cases_by_status(
trigger_case_id: int,
previous_status: Optional[str],
new_status: Optional[str],
) -> None:
"""Set start_date for waiting cases when their status dependency is met."""
normalized_status = str(status_value or "").strip()
normalized_status = str(new_status or "").strip()
if not normalized_status:
return
try:
updated_rows = execute_query(
waiting_rows = execute_query(
"""
UPDATE sag_sager
SET start_date = NOW(), updated_at = NOW()
SELECT id, deferred_until_status
FROM sag_sager
WHERE deleted_at IS NULL
AND deferred_until_case_id = %s
AND deferred_until_status IS NOT NULL
AND start_date IS NULL
AND LOWER(TRIM(deferred_until_status)) = LOWER(TRIM(%s))
""",
(trigger_case_id,),
) or []
matching_ids = [
int(row["id"])
for row in waiting_rows
if _deferred_status_matches(row.get("deferred_until_status"), previous_status, normalized_status)
]
if not matching_ids:
return
placeholders = ",".join(["%s"] * len(matching_ids))
updated_rows = execute_query(
f"""
UPDATE sag_sager
SET start_date = NOW(), updated_at = NOW()
WHERE id IN ({placeholders})
RETURNING id
""",
(trigger_case_id, normalized_status),
tuple(matching_ids),
) or []
if updated_rows:
@ -1018,6 +1091,8 @@ async def update_sag(sag_id: int, updates: dict):
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
if "deferred_until_status" in updates:
updates["deferred_until_status"] = _normalize_deferred_statuses(updates.get("deferred_until_status"))
if "priority" in updates:
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
if "ansvarlig_bruger_id" in updates:
@ -1066,7 +1141,7 @@ async def update_sag(sag_id: int, updates: dict):
if "status" in updates:
new_status = str((result[0] or {}).get("status") or "").strip().lower()
if new_status and new_status != previous_status:
_activate_waiting_cases_by_status(sag_id, new_status)
_activate_waiting_cases_by_status(sag_id, previous_status, new_status)
logger.info("✅ Case updated: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to update case")

View File

@ -1679,19 +1679,20 @@
.case-tabs-topbar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
gap: 0.7rem;
align-items: start;
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
padding: 0.7rem;
border-radius: 12px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.case-tabs-topbar.topbar-primary {
grid-template-columns: 105px minmax(170px, 1.1fr) minmax(170px, 1.1fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr);
background: linear-gradient(135deg, rgba(15,76,117,0.12), rgba(15,76,117,0.04));
border: 1px solid rgba(15,76,117,0.25);
box-shadow: 0 4px 16px rgba(15,76,117,0.12);
background: linear-gradient(140deg, rgba(15,76,117,0.11), rgba(15,76,117,0.03));
border: 1px solid rgba(15,76,117,0.22);
box-shadow: 0 3px 12px rgba(15,76,117,0.1);
margin-bottom: 1rem;
}
@ -1710,7 +1711,7 @@
.topbar-primary .case-tabs-topbar-label {
color: color-mix(in srgb, var(--accent) 75%, #2f3a45);
opacity: 0.95;
font-size: 0.58rem;
font-size: 0.62rem;
letter-spacing: 0.1em;
}
@ -1841,15 +1842,21 @@
}
.case-tabs-topbar.topbar-secondary {
grid-template-columns: repeat(8, minmax(145px, 1fr));
grid-template-columns: repeat(8, minmax(150px, 1fr));
}
.case-tabs-topbar-item {
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
border: 1px solid rgba(0,0,0,0.06);
border-radius: 8px;
padding: 0.55rem 0.65rem;
border-radius: 10px;
padding: 0.58rem 0.68rem;
min-width: 0;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.case-tabs-topbar-item:hover {
border-color: rgba(15, 76, 117, 0.28);
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.08);
}
.case-tabs-topbar-label {
@ -1909,46 +1916,6 @@
display: none;
}
.topbar-secondary .case-tabs-topbar-item.field-add {
background: #eaf8ee;
border-color: #cbe9d4;
}
.topbar-secondary .case-tabs-topbar-item.field-type {
background: #e7f3ff;
border-color: #c7e2ff;
}
.topbar-secondary .case-tabs-topbar-item.field-created {
background: #eefaf1;
border-color: #d4f0dc;
}
.topbar-secondary .case-tabs-topbar-item.field-priority {
background: #f2ecff;
border-color: #ddd0ff;
}
.topbar-secondary .case-tabs-topbar-item.field-start {
background: #fff8e8;
border-color: #f4e4b6;
}
.topbar-secondary .case-tabs-topbar-item.field-start-before {
background: #fff2e8;
border-color: #f6d9c1;
}
.topbar-secondary .case-tabs-topbar-item.field-deadline {
background: #ffeaea;
border-color: #f6c5c5;
}
.topbar-secondary .case-tabs-topbar-item.field-anydesk {
background: #eaf3ff;
border-color: #c8ddff;
}
.topbar-secondary-action {
border: 1px solid rgba(0,0,0,0.12);
background: rgba(255,255,255,0.72);
@ -2002,10 +1969,31 @@
.topbar-deferred-shortcuts {
display: flex;
gap: 0.35rem;
margin-top: 0.45rem;
margin-top: 0.35rem;
flex-wrap: wrap;
}
.topbar-deferred-current {
margin-top: 0.4rem;
padding: 0.3rem 0.45rem;
border-radius: 8px;
font-size: 0.72rem;
line-height: 1.25;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.52);
border: 1px dashed rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 0.28rem;
}
.topbar-deferred-current.is-set {
color: #0f4c75;
border-style: solid;
border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.09);
}
.topbar-mini-trigger {
border: 1px solid rgba(0,0,0,0.14);
background: rgba(255,255,255,0.75);
@ -2014,7 +2002,8 @@
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
padding: 0.35rem 0.55rem;
padding: 0.34rem 0.58rem;
min-height: 30px;
letter-spacing: 0.01em;
}
@ -2055,6 +2044,18 @@
border-color: rgba(230, 190, 90, 0.35);
}
[data-bs-theme="dark"] .topbar-deferred-current {
color: rgba(236, 242, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.16);
}
[data-bs-theme="dark"] .topbar-deferred-current.is-set {
color: #9dd7ff;
border-color: rgba(108, 188, 255, 0.42);
background: rgba(19, 100, 154, 0.24);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before {
background: rgba(150, 90, 30, 0.24);
border-color: rgba(230, 160, 90, 0.35);
@ -2282,6 +2283,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.case-tabs-topbar.topbar-secondary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.topbar-primary .case-tabs-topbar-item {
border-left: none;
border-top: 1px solid rgba(15,76,117,0.2);
@ -2296,6 +2301,10 @@
.case-tabs-topbar {
grid-template-columns: 1fr;
}
.case-tabs-topbar.topbar-secondary {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
@ -2402,6 +2411,15 @@
<i class="bi bi-x-lg"></i>
</button>
</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 class="case-tabs-topbar-item field-start-before">
<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('løst')">Løst</button>
</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 class="case-tabs-topbar-item field-deadline">
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
@ -3366,17 +3403,22 @@
</option>
{% endfor %}
</select>
<select class="form-select form-select-sm mt-2" id="deferredStatusSelect">
<option value="">Vælg status</option>
{% set deferred_status_values = (case.deferred_until_status or '').lower().replace(', ', ',').split(',') %}
<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 %}
<option value="{{ st }}" {% if case.deferred_until_status == st %}selected{% endif %}>
<option value="{{ st }}" {% if st|lower in deferred_status_values %}selected{% endif %}>
{{ st }}
</option>
{% endfor %}
</select>
<div class="form-text mt-1">Du kan vælge flere statuser (Cmd/Ctrl + klik).</div>
<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="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 class="modal-footer">
@ -3388,6 +3430,38 @@
</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 -->
<div class="modal fade" id="moduleControlModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@ -9932,6 +10006,28 @@
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) {
const target = String(statusKey || '').trim().toLowerCase();
const fallback = target === 'lukket' ? 'luk' : target;
@ -9946,19 +10042,25 @@
const statusSelect = document.getElementById('deferredStatusSelect');
if (statusSelect) {
statusSelect.value = value;
setDeferredSelectedStatuses([value]);
}
}
async function updateDeferredCaseAndStatus(caseId, status) {
async function updateDeferredCaseAndStatus(caseId, statuses) {
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}`, {
method: 'PATCH',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
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) {
@ -9973,8 +10075,7 @@
function setDeferredCaseFromInputs() {
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
updateDeferredCaseAndStatus(caseSelect.value || null, getDeferredSelectedStatuses());
}
function applyDeferredStatusTrigger(primaryStatus, fallbackContains, missingMessage) {
@ -9996,7 +10097,7 @@
return;
}
statusSelect.value = matchValue;
setDeferredSelectedStatuses([matchValue]);
saveDeferredAll();
}
@ -10008,29 +10109,36 @@
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() {
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
caseSelect.value = '';
statusSelect.value = '';
setDeferredSelectedStatuses([]);
updateDeferredCaseAndStatus(null, null);
}
function saveDeferredAll() {
const input = document.getElementById('deferredUntilInput');
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
updateDeferredUntil(input.value || null);
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
updateDeferredCaseAndStatus(caseSelect.value || null, getDeferredSelectedStatuses());
}
function clearDeferredAll() {
const input = document.getElementById('deferredUntilInput');
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
input.value = '';
caseSelect.value = '';
statusSelect.value = '';
setDeferredSelectedStatuses([]);
updateDeferredUntil(null);
updateDeferredCaseAndStatus(null, null);
}
@ -10233,6 +10341,63 @@
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() {
const topUser = document.getElementById('tabsAssignmentUserSelect');
const topGroup = document.getElementById('tabsAssignmentGroupSelect');

View File

@ -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><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
</ul>
</li>
<li class="nav-item dropdown">
@ -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)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
<i class="bi bi-question-circle"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
<i class="bi bi-moon-fill"></i>
</button>
@ -596,11 +599,22 @@ window.addEventListener('unhandledrejection', function(event) {
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
const contextManualBtn = document.getElementById('contextManualBtn');
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
const profileModalEl = document.getElementById('profileModal');
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
const globalSearchInput = document.getElementById('globalSearchInput');
function getCurrentModuleContext() {
const path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/sag')) return 'sag';
if (path.startsWith('/hardware')) return 'hardware';
if (path.startsWith('/emails')) return 'mail';
if (path.startsWith('/ordre')) return 'salg';
if (path.startsWith('/customers') || path.startsWith('/contacts')) return 'crm';
return '';
}
function openGlobalSearchModal() {
searchModal.show();
setTimeout(() => {
@ -640,6 +654,18 @@ window.addEventListener('unhandledrejection', function(event) {
openRemindersModalTab();
});
}
if (contextManualBtn) {
contextManualBtn.addEventListener('click', (e) => {
e.preventDefault();
const module = getCurrentModuleContext();
if (module) {
window.location.href = `/manual?module=${encodeURIComponent(module)}`;
return;
}
window.location.href = '/manual';
});
}
// Search input listener with debounce
let searchTimeout;

View File

@ -131,6 +131,8 @@ from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
from app.modules.manual.backend import router as manual_api
from app.modules.manual.frontend import views as manual_views
# Configure logging
logging.basicConfig(
@ -427,6 +429,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devp
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
@ -460,6 +463,7 @@ app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"])
app.include_router(orders_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"])
app.include_router(manual_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views

View 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();

View 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'
);

View 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;

View 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'
);