Compare commits

..

2 Commits

Author SHA1 Message Date
Christian
ee8c517acc 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.
2026-04-05 21:48:59 +02:00
Christian
807c68679e feat: Enhance case listing and detail views with improved filtering and relation handling
- Added filtering for cases based on start date in `sager_liste`.
- Improved fallback relation tree rendering in `sag_detaljer` when tree builder fails.
- Normalized relation types in `RelationService` for consistency.
- Updated relation type display in templates with new styles and improved semantics.
- Enhanced customer handling in detail view with edit functionality.
- Updated various labels for clarity in the UI.
- Added new buttons for deferred status shortcuts in the detail view.
- Improved tag picker resilience by decoupling from optional tag group API.
2026-04-04 02:46:37 +02:00
20 changed files with 2474 additions and 133 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

@ -169,6 +169,116 @@ def _validate_customer_id(customer_id: Optional[int], field_name: str = "custome
if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]:
if value is None:
return None
raw_items: List[str] = []
if isinstance(value, (list, tuple, set)):
raw_items = [str(v or "") for v in value]
else:
raw_items = re.split(r"[,;\n]", str(value or ""))
cleaned: List[str] = []
seen = set()
for item in raw_items:
token = str(item or "").strip()
if not token:
continue
key = token.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(token)
if not cleaned:
return None
return ", ".join(cleaned)
def _deferred_status_matches(
deferred_until_status: Optional[str],
previous_status: Optional[str],
new_status: Optional[str],
) -> bool:
normalized_new = str(new_status or "").strip().lower()
normalized_prev = str(previous_status or "").strip().lower()
if not normalized_new:
return False
status_values = [
str(part or "").strip().lower()
for part in re.split(r"[,;\n]", str(deferred_until_status or ""))
if str(part or "").strip()
]
if not status_values:
return False
if "__any_change__" in status_values and normalized_prev and normalized_prev != normalized_new:
return True
return normalized_new in status_values
def _activate_waiting_cases_by_status(
trigger_case_id: int,
previous_status: Optional[str],
new_status: Optional[str],
) -> None:
"""Set start_date for waiting cases when their status dependency is met."""
normalized_status = str(new_status or "").strip()
if not normalized_status:
return
try:
waiting_rows = execute_query(
"""
SELECT id, deferred_until_status
FROM sag_sager
WHERE deleted_at IS NULL
AND deferred_until_case_id = %s
AND deferred_until_status IS NOT NULL
AND start_date IS NULL
""",
(trigger_case_id,),
) or []
matching_ids = [
int(row["id"])
for row in waiting_rows
if _deferred_status_matches(row.get("deferred_until_status"), previous_status, normalized_status)
]
if not matching_ids:
return
placeholders = ",".join(["%s"] * len(matching_ids))
updated_rows = execute_query(
f"""
UPDATE sag_sager
SET start_date = NOW(), updated_at = NOW()
WHERE id IN ({placeholders})
RETURNING id
""",
tuple(matching_ids),
) or []
if updated_rows:
logger.info(
"✅ Activated %s waiting case(s) from trigger case %s with status '%s'",
len(updated_rows),
trigger_case_id,
normalized_status,
)
except Exception as exc:
logger.warning(
"⚠️ Could not activate waiting cases for trigger case %s and status '%s': %s",
trigger_case_id,
normalized_status,
exc,
)
# ============================================================================
# QUICKCREATE AI ANALYSIS
# ============================================================================
@ -577,6 +687,7 @@ async def list_sager(
if not include_deferred:
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
query += " AND (start_date IS NULL OR start_date <= NOW())"
if status:
query += " AND s.status = %s"
@ -717,6 +828,8 @@ async def create_sag(data: dict):
logger.info("✅ Case created: %s", result[0]["id"])
return result[0]
raise HTTPException(status_code=500, detail="Failed to create case")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case")
@ -957,10 +1070,15 @@ async def update_sag(sag_id: int, updates: dict):
"""Update a case."""
try:
# Check if case exists
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
check = execute_query(
"SELECT id, status FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not check:
raise HTTPException(status_code=404, detail="Case not found")
previous_status = str((check[0] or {}).get("status") or "").strip().lower()
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates:
updates["template_key"] = updates.get("type")
@ -973,6 +1091,8 @@ async def update_sag(sag_id: int, updates: dict):
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
if "deferred_until_status" in updates:
updates["deferred_until_status"] = _normalize_deferred_statuses(updates.get("deferred_until_status"))
if "priority" in updates:
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
if "ansvarlig_bruger_id" in updates:
@ -1018,6 +1138,10 @@ async def update_sag(sag_id: int, updates: dict):
result = execute_query(query, tuple(params))
if result:
if "status" in updates:
new_status = str((result[0] or {}).get("status") or "").strip().lower()
if new_status and new_status != previous_status:
_activate_waiting_cases_by_status(sag_id, previous_status, new_status)
logger.info("✅ Case updated: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to update case")
@ -1387,6 +1511,74 @@ async def list_case_customers(sag_id: int):
logger.error("❌ Error listing case customers: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case customers")
@router.post("/sag/{sag_id}/customer/replace")
async def replace_case_customer(sag_id: int, payload: dict):
"""Replace the primary customer on a case and keep relations synchronized."""
try:
existing_case = execute_query_single(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not existing_case:
raise HTTPException(status_code=404, detail="Case not found")
customer_id = _coerce_optional_int((payload or {}).get("customer_id"), "customer_id")
if customer_id is None:
raise HTTPException(status_code=400, detail="customer_id is required")
_validate_customer_id(customer_id)
if table_has_column("sag_sager", "customer_id"):
execute_query(
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
(customer_id, sag_id),
)
if _table_exists("sag_kunder"):
execute_query(
"""
UPDATE sag_kunder
SET deleted_at = NOW()
WHERE sag_id = %s
AND customer_id <> %s
AND deleted_at IS NULL
AND LOWER(COALESCE(role, '')) = 'kunde'
""",
(sag_id, customer_id),
)
existing_link = execute_query_single(
"""
SELECT id
FROM sag_kunder
WHERE sag_id = %s
AND customer_id = %s
AND deleted_at IS NULL
LIMIT 1
""",
(sag_id, customer_id),
)
if existing_link:
execute_query(
"UPDATE sag_kunder SET role = %s WHERE id = %s",
("Kunde", existing_link["id"]),
)
else:
execute_query(
"INSERT INTO sag_kunder (sag_id, customer_id, role) VALUES (%s, %s, %s)",
(sag_id, customer_id, "Kunde"),
)
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error replacing case customer: %s", e)
raise HTTPException(status_code=500, detail="Failed to replace case customer")
@router.post("/sag/{sag_id}/customers")
async def add_case_customer(sag_id: int, data: dict):
"""Add a customer to a case."""

View File

@ -237,6 +237,7 @@ async def sager_liste(
query += " OR s.deferred_until <= NOW()"
query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
if status:
query += " AND s.status = %s"
@ -289,6 +290,10 @@ async def sager_liste(
"""
fallback_params = []
if not include_deferred:
fallback_query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
fallback_query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
if status:
fallback_query += " AND s.status = %s"
fallback_params.append(status)
@ -498,9 +503,50 @@ async def sag_detaljer(request: Request, sag_id: int):
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
# Fallback: if tree builder fails/returns empty but relations exist, render a minimal flat tree.
if not relation_tree and relationer:
try:
root_node = {
"case": {
"id": sag.get("id"),
"titel": sag.get("titel"),
"status": sag.get("status"),
"type": sag.get("type"),
"template_key": sag.get("template_key"),
},
"relation_type": None,
"relation_id": None,
"is_current": True,
"children": [],
}
seen_related = set()
for rel in relationer or []:
source_id = rel.get("kilde_sag_id")
target_id = rel.get("målsag_id")
related_id = target_id if source_id == sag_id else source_id
if not related_id or related_id in seen_related:
continue
seen_related.add(related_id)
root_node["children"].append({
"case": {
"id": related_id,
"titel": rel.get("mål_titel") if source_id == sag_id else rel.get("kilde_titel"),
"status": None,
"type": None,
"template_key": None,
},
"relation_type": rel.get("relationstype") or "Relateret til",
"relation_id": rel.get("id"),
"is_current": False,
"children": [],
})
relation_tree = [root_node]
except Exception as fallback_err:
logger.warning("⚠️ Could not build fallback relation tree: %s", fallback_err)
# Fetch customer info if customer_id exists
customer = None

View File

@ -4,6 +4,11 @@ from app.core.database import execute_query
class RelationService:
"""Service for handling case relations (Sager)"""
@staticmethod
def _normalize_relation_type(value: Optional[str]) -> str:
text = str(value or "").strip()
return text or "Relateret til"
@staticmethod
def get_relation_tree(root_id: int) -> List[Dict]:
"""
@ -33,7 +38,16 @@ class RelationService:
# 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})"
tree_cases_query = f"""
SELECT
id,
titel,
status,
template_key,
COALESCE(template_key, 'ticket') AS type
FROM sag_sager
WHERE id IN ({placeholders})
"""
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases
@ -53,7 +67,8 @@ class RelationService:
# Helper to normalize relation types
# Now that we cleaned DB, we expect standard Danish terms, but good to be safe
def get_direction(k, m, rtype):
rtype_lower = rtype.lower()
normalized_type = RelationService._normalize_relation_type(rtype)
rtype_lower = normalized_type.lower()
if rtype_lower in ['afledt af', 'derived from']:
return m, k # m is parent of k
if rtype_lower in ['årsag til', 'cause of']:
@ -66,7 +81,8 @@ class RelationService:
processed_edges = set()
for edge in tree_edges:
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype']
k, m = edge['kilde_sag_id'], edge['målsag_id']
rtype = RelationService._normalize_relation_type(edge.get('relationstype'))
# Dedup edges (bi-directional check)
edge_key = tuple(sorted((k,m))) + (rtype,)
@ -119,7 +135,7 @@ class RelationService:
path_visited.add(cid)
# Sort children for consistent display
children_data = sorted(children_map.get(cid, []), key=lambda x: x['type'])
children_data = sorted(children_map.get(cid, []), key=lambda x: str(x.get('type') or '').lower())
children_nodes = []
for child_info in children_data:

File diff suppressed because it is too large Load Diff

View File

@ -418,8 +418,8 @@
<th style="width: 170px;">Gruppe/Level</th>
<th style="width: 240px;">Næste todo</th>
<th style="width: 120px;">Opret.</th>
<th style="width: 120px;">Start arbejde</th>
<th style="width: 140px;">Start inden</th>
<th style="width: 120px;">Arbejdsstart</th>
<th style="width: 140px;">Start senest</th>
<th style="width: 120px;">Deadline</th>
</tr>
</thead>

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BMC Hub{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f4c75'/%3E%3Ctext x='32' y='42' text-anchor='middle' font-size='30' font-family='Arial, sans-serif' fill='white'%3EB%3C/text%3E%3C/svg%3E">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
@ -256,7 +257,7 @@
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
</ul>
</li>
<li class="nav-item dropdown">
@ -313,6 +314,9 @@
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
<i class="bi bi-question-circle"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
<i class="bi bi-moon-fill"></i>
</button>
@ -546,8 +550,21 @@
</div>
{% endblock %}
<script>
window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason;
const msg = String((reason && reason.message) || reason || '');
const stack = String((reason && reason.stack) || '');
const combined = (msg + '\n' + stack).toLowerCase();
// Known Safari/extension autofill overlay crash; ignore noisy external script rejection.
if (combined.includes('bootstrap-autofill-overlay.js') || combined.includes('autocompletetype.includes')) {
event.preventDefault();
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.0"></script>
<script src="/static/js/tag-picker.js?v=2.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script>
@ -582,11 +599,22 @@
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
const contextManualBtn = document.getElementById('contextManualBtn');
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
const profileModalEl = document.getElementById('profileModal');
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
const globalSearchInput = document.getElementById('globalSearchInput');
function getCurrentModuleContext() {
const path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/sag')) return 'sag';
if (path.startsWith('/hardware')) return 'hardware';
if (path.startsWith('/emails')) return 'mail';
if (path.startsWith('/ordre')) return 'salg';
if (path.startsWith('/customers') || path.startsWith('/contacts')) return 'crm';
return '';
}
function openGlobalSearchModal() {
searchModal.show();
setTimeout(() => {
@ -627,6 +655,18 @@
});
}
if (contextManualBtn) {
contextManualBtn.addEventListener('click', (e) => {
e.preventDefault();
const module = getCurrentModuleContext();
if (module) {
window.location.href = `/manual?module=${encodeURIComponent(module)}`;
return;
}
window.location.href = '/manual';
});
}
// Search input listener with debounce
let searchTimeout;
if (globalSearchInput) {

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

View File

@ -9,6 +9,7 @@ class TagPicker {
this.searchInput = null;
this.resultsContainer = null;
this.allTags = [];
this.tagGroups = [];
this.filteredTags = [];
this.selectedIndex = 0;
this.onSelectCallback = null;
@ -155,7 +156,9 @@ class TagPicker {
const response = await fetch('/api/v1/tags?is_active=true');
if (!response.ok) throw new Error('Failed to load tags');
this.allTags = await response.json();
this.tagGroups = await this.loadTagGroups();
// Tag groups are optional metadata. Some hubs do not expose the endpoint,
// so keep picker resilient by not depending on a second API call.
this.tagGroups = [];
console.log('🏷️ Loaded tags:', this.allTags.length);
this.filteredTags = [...this.allTags];
this.renderResults();
@ -164,17 +167,6 @@ class TagPicker {
}
}
async loadTagGroups() {
try {
const response = await fetch('/api/v1/tags/groups');
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('🏷️ Error loading tag groups:', error);
return [];
}
}
filterTags(query) {
if (!query.trim()) {
this.filteredTags = [...this.allTags];