feat(task-templates): implement task template MVP with modal selector and tag actions

- Added task template and task template items tables to the database.
- Introduced case template runs and run items tables for tracking template executions.
- Created a new JavaScript module for task template selection with a modal interface.
- Integrated tag actions to open the task template selector modal upon tag addition.
- Updated backend to resolve tag actions and return them in the response when adding tags.
- Enhanced the tag picker to handle actions and trigger the appropriate modal.
- Added permissions and group permissions for managing task templates.
This commit is contained in:
Christian 2026-05-01 20:58:13 +02:00
parent 785a2d3ffe
commit f2c8af4680
13 changed files with 2851 additions and 31 deletions

View File

@ -4190,7 +4190,21 @@
// Set default context for keyboard shortcuts (Option+Shift+T)
if (window.setTagPickerContext) {
window.setTagPickerContext('case', {{ case.id }}, () => syncCaseTagsUi());
window.setTagPickerContext('case', {{ case.id }}, (tag, addResult) => {
syncCaseTagsUi();
const actionType = addResult && addResult.action ? addResult.action.type : null;
const shouldOpenTemplateModal = actionType === 'open_task_template_modal'
|| String((tag && tag.name) || '').toLowerCase() === 'opgave_template';
if (shouldOpenTemplateModal && typeof window.openTaskTemplateSelectorModal === 'function') {
window.openTaskTemplateSelectorModal({
action: addResult ? addResult.action : null,
entity_type: 'case',
entity_id: caseId,
});
}
});
}
// Load Hardware & Locations
@ -5381,7 +5395,7 @@
return normalizeLegacyTags(legacyTags);
};
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`);
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`, { credentials: 'include' });
if (response.ok) {
const genericTags = await response.json();
tags = Array.isArray(genericTags) ? genericTags : [];
@ -5434,7 +5448,7 @@
if (!suggestionsContainer) return;
try {
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`);
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`, { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente forslag');
const suggestions = await response.json();
@ -5470,6 +5484,7 @@
const response = await fetch('/api/v1/tags/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
});
@ -5478,6 +5493,17 @@
throw new Error(error.detail || 'Kunne ikke tilfoeje tag');
}
const addResult = await response.json().catch(() => ({}));
if (addResult && addResult.action) {
window.dispatchEvent(new CustomEvent('hub:tag-action', {
detail: {
action: addResult.action,
entity_type: 'case',
entity_id: caseId,
}
}));
}
await syncCaseTagsUi();
if (typeof showNotification === 'function') {
showNotification('Tag tilfoejet', 'success');
@ -5520,6 +5546,8 @@
await loadCaseTagSuggestions();
}
window.syncCaseTagsUi = syncCaseTagsUi;
let todoUserId = null;
function getTodoUserId() {
@ -6152,14 +6180,22 @@
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi()); return false;"
onclick="event.stopPropagation(); window.showTagPicker('case', {{ case.id }}, (tag, addResult) => { syncCaseTagsUi(); const actionType = addResult && addResult.action ? addResult.action.type : null; const shouldOpenTemplateModal = actionType === 'open_task_template_modal' || String((tag && tag.name) || '').toLowerCase() === 'opgave_template'; if (shouldOpenTemplateModal && typeof window.openTaskTemplateSelectorModal === 'function') { window.openTaskTemplateSelectorModal({ action: addResult ? addResult.action : null, entity_type: 'case', entity_id: {{ case.id }} }); } }); return false;"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body" style="max-height: 240px; overflow: auto;">
<div id="case-tags-module" class="mb-2">
<div class="p-2 text-muted small">Indlaeser tags...</div>
{% if tags and tags|length > 0 %}
{% for tag in tags %}
<span class="badge me-1 mb-1" style="background-color: #0f4c75;">
<i class="bi bi-tag"></i> {{ tag.tag_navn or tag.name or 'Tag' }}
</span>
{% endfor %}
{% else %}
<div class="p-2 text-muted small">Ingen tags paa sagen endnu</div>
{% endif %}
</div>
<div class="border-top pt-2">
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
@ -15509,6 +15545,127 @@
})();
</script>
<!-- ── Case tags resilient bootstrap ─────────────────────────────────── -->
<script>
(function () {
function getCaseIdFromPath() {
const match = String(window.location.pathname || '').match(/\/sag\/(\d+)/);
return match ? Number(match[1]) : null;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function loadCaseTagsFallback(caseId) {
const moduleContainer = document.getElementById('case-tags-module');
if (!moduleContainer || !caseId) return;
try {
let tags = [];
const genericResponse = await fetch(`/api/v1/tags/entity/case/${caseId}`, { credentials: 'include' });
if (genericResponse.ok) {
tags = await genericResponse.json();
}
if (!Array.isArray(tags) || tags.length === 0) {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags`, { credentials: 'include' });
if (legacyResponse.ok) {
const legacyRows = await legacyResponse.json();
tags = (Array.isArray(legacyRows) ? legacyRows : []).map((row) => ({
id: row.id || row.tag_id,
name: row.tag_navn || row.name || 'Tag',
color: row.color || '#0f4c75',
icon: row.icon || 'bi-tag'
}));
}
}
if (!Array.isArray(tags) || tags.length === 0) {
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paa sagen endnu</div>';
return;
}
moduleContainer.innerHTML = tags.map((tag) => `
<span class="badge me-1 mb-1" style="background-color: ${tag.color || '#0f4c75'};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
</span>
`).join('');
} catch (error) {
console.error('Case tags fallback failed:', error);
moduleContainer.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning af tags</div>';
}
}
function maybeOpenTemplateModal(tag, addResult, caseId) {
const actionType = addResult && addResult.action ? addResult.action.type : null;
const shouldOpen = actionType === 'open_task_template_modal'
|| String((tag && tag.name) || '').toLowerCase() === 'opgave_template';
if (shouldOpen && typeof window.openTaskTemplateSelectorModal === 'function') {
window.openTaskTemplateSelectorModal({
action: addResult ? addResult.action : null,
entity_type: 'case',
entity_id: caseId,
});
}
}
function wireTagsPlusButton(caseId) {
const tagsCard = document.querySelector('.right-module-card[data-module="tags"]');
const plusBtn = tagsCard ? tagsCard.querySelector('button.btn.btn-sm.btn-outline-primary') : null;
if (!plusBtn || !caseId) return;
plusBtn.onclick = function (event) {
event.preventDefault();
event.stopPropagation();
if (typeof window.showTagPicker !== 'function') return false;
window.showTagPicker('case', caseId, async function (tag, addResult) {
await loadCaseTagsFallback(caseId);
maybeOpenTemplateModal(tag, addResult, caseId);
});
return false;
};
}
let initialized = false;
function initCaseTagsFallback() {
if (initialized) return;
initialized = true;
const caseId = getCaseIdFromPath();
if (!caseId) return;
loadCaseTagsFallback(caseId);
wireTagsPlusButton(caseId);
if (typeof window.setTagPickerContext === 'function') {
window.setTagPickerContext('case', caseId, async function (tag, addResult) {
await loadCaseTagsFallback(caseId);
maybeOpenTemplateModal(tag, addResult, caseId);
});
}
window.__caseTagsFallbackReady = true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCaseTagsFallback, { once: true });
} else {
initCaseTagsFallback();
}
window.addEventListener('pageshow', initCaseTagsFallback, { once: true });
})();
</script>
<!-- ── Beskrivelse inline editor ───────────────────────────────────────── -->
<script>
(function () {

View File

@ -1493,6 +1493,11 @@
display: none;
}
/* Keep tags module content visible even when compact state is active. */
.card[data-module="tags"].module-empty-compact .card-body {
display: block;
}
.card[data-module].module-empty-compact .card-header,
.card[data-module].module-empty-compact .module-header {
margin-bottom: 0;
@ -4679,7 +4684,9 @@
// Set default context for keyboard shortcuts (Option+Shift+T)
if (window.setTagPickerContext) {
window.setTagPickerContext('case', {{ case.id }}, () => syncCaseTagsUi());
window.setTagPickerContext('case', {{ case.id }}, () => {
syncCaseTagsUi();
});
}
// Load Hardware & Locations
@ -5885,7 +5892,7 @@
return normalizeLegacyTags(legacyTags);
};
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`);
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`, { credentials: 'include' });
if (response.ok) {
const genericTags = await response.json();
tags = Array.isArray(genericTags) ? genericTags : [];
@ -5910,14 +5917,16 @@
}
moduleContainer.innerHTML = tags.map((tag) => `
<span class="badge me-1 mb-1" style="background-color: ${tag.color};">
<span class="badge me-1 mb-1 ${isTemplateTriggerTag(tag.name) ? 'case-template-tag-trigger' : ''}" data-tag-name="${escapeHtml(tag.name)}" style="background-color: ${tag.color}; ${isTemplateTriggerTag(tag.name) ? 'cursor: pointer;' : ''}">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
${tag.id ? `<button type="button" class="btn-close btn-close-white btn-sm ms-1"
onclick="removeCaseTagAndSync(${tag.id})"
onclick="event.stopPropagation(); removeCaseTagAndSync(${tag.id})"
style="font-size: 0.6rem; vertical-align: middle;"></button>` : ''}
</span>
`).join('');
wireCaseTagTemplateClicks(moduleContainer);
if (usingLegacyCaseTags) {
moduleContainer.insertAdjacentHTML(
'beforeend',
@ -5938,7 +5947,7 @@
if (!suggestionsContainer) return;
try {
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`);
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`, { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente forslag');
const suggestions = await response.json();
@ -5974,6 +5983,7 @@
const response = await fetch('/api/v1/tags/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
});
@ -5982,6 +5992,17 @@
throw new Error(error.detail || 'Kunne ikke tilfoeje tag');
}
const addResult = await response.json().catch(() => ({}));
if (addResult && addResult.action) {
window.dispatchEvent(new CustomEvent('hub:tag-action', {
detail: {
action: addResult.action,
entity_type: 'case',
entity_id: caseId,
}
}));
}
await syncCaseTagsUi();
if (typeof showNotification === 'function') {
showNotification('Tag tilfoejet', 'success');
@ -5992,27 +6013,55 @@
}
async function removeCaseTagAndSync(tagId) {
const numericTagId = Number(tagId || 0);
if (!numericTagId) {
throw new Error('Ugyldigt tag-id');
}
if (!confirm('Fjern dette tag?')) {
return;
}
let removed = false;
let genericError = '';
let legacyError = '';
// Try generic entity-tag deletion first.
try {
if (window.removeEntityTag) {
await window.removeEntityTag('case', caseId, tagId, 'case-tags-module');
} else {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags/${tagId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!legacyResponse.ok) {
throw new Error('Kunne ikke slette tag');
}
}
} catch (error) {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags/${tagId}`, {
const genericResponse = await fetch(`/api/v1/tags/entity/case/${caseId}/${numericTagId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!legacyResponse.ok) {
throw error;
if (genericResponse.ok) {
removed = true;
} else {
genericError = `generic ${genericResponse.status}`;
}
} catch (error) {
genericError = (error && error.message) ? error.message : 'generic request failed';
}
// Fallback to legacy sag_tags deletion when generic did not remove anything.
if (!removed) {
try {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags/${numericTagId}`, {
method: 'DELETE',
credentials: 'include'
});
if (legacyResponse.ok) {
removed = true;
} else {
legacyError = `legacy ${legacyResponse.status}`;
}
} catch (error) {
legacyError = (error && error.message) ? error.message : 'legacy request failed';
}
}
if (!removed) {
throw new Error(`Kunne ikke slette tag (${genericError || 'generic fail'} / ${legacyError || 'legacy fail'})`);
}
await syncCaseTagsUi();
}
@ -6024,6 +6073,45 @@
await loadCaseTagSuggestions();
}
function isTemplateTriggerTag(tagName) {
const normalized = String(tagName || '').trim().toLowerCase();
return normalized === 'opgave_template' || normalized === 'template';
}
function openTemplateSelectorFromTagClick(tagName) {
if (!isTemplateTriggerTag(tagName)) return;
if (typeof window.openTaskTemplateSelectorModal !== 'function') return;
window.openTaskTemplateSelectorModal({
entity_type: 'case',
entity_id: caseId,
});
}
function wireCaseTagTemplateClicks(container) {
if (!container) return;
container.querySelectorAll('.case-template-tag-trigger').forEach((el) => {
const tagName = el.getAttribute('data-tag-name') || '';
el.title = 'Klik for at vaelge opgave-template';
el.onclick = function () {
openTemplateSelectorFromTagClick(tagName);
};
el.onkeydown = function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openTemplateSelectorFromTagClick(tagName);
}
};
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
});
}
window.openTemplateSelectorFromTagClick = openTemplateSelectorFromTagClick;
window.syncCaseTagsUi = syncCaseTagsUi;
window.loadTodoSteps = loadTodoSteps;
window.removeCaseTagAndSync = removeCaseTagAndSync;
let todoUserId = null;
function getTodoUserId() {
@ -6652,7 +6740,7 @@
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="unknown">
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="{{ 'true' if tags and tags|length > 0 else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
<button type="button" class="btn btn-sm btn-outline-primary"
@ -6661,9 +6749,23 @@
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body" style="max-height: 240px; overflow: auto;">
<div class="card-body">
<div id="case-tags-module" class="mb-2">
<div class="p-2 text-muted small">Indlaeser tags...</div>
{% if tags and tags|length > 0 %}
{% for tag in tags %}
<span class="badge me-1 mb-1 {% if (tag.tag_navn or tag.name or '')|lower in ['opgave_template', 'template'] %}case-template-tag-trigger{% endif %}" data-tag-name="{{ tag.tag_navn or tag.name or 'Tag' }}" style="background-color: #0f4c75; {% if (tag.tag_navn or tag.name or '')|lower in ['opgave_template', 'template'] %}cursor: pointer;{% endif %}">
<i class="bi bi-tag"></i> {{ tag.tag_navn or tag.name or 'Tag' }}
{% if tag.id or tag.tag_id %}
<button type="button" class="btn-close btn-close-white btn-sm ms-1"
onclick="event.stopPropagation(); removeCaseTagAndSync({{ tag.id or tag.tag_id }})"
style="font-size: 0.6rem; vertical-align: middle;"
title="Fjern tag"></button>
{% endif %}
</span>
{% endfor %}
{% else %}
<div class="p-2 text-muted small">Ingen tags paa sagen endnu</div>
{% endif %}
</div>
<div class="border-top pt-2">
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
@ -13455,7 +13557,7 @@
const hasContent = moduleHasContent(el);
const isTimeModule = moduleName === 'time';
const isShippingModule = moduleName === 'shipping';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'tags' && !isTimeModule;
const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
@ -16871,6 +16973,292 @@
})();
</script>
<!-- ── Case tags resilient bootstrap ─────────────────────────────────── -->
<script>
(function () {
function getCaseIdFromPath() {
const match = String(window.location.pathname || '').match(/\/sag\/(\d+)/);
return match ? Number(match[1]) : null;
}
function isTemplateTriggerName(tagName) {
const normalized = String(tagName || '').trim().toLowerCase();
return normalized === 'template' || normalized === 'opgave_template';
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function loadCaseTagsFallback(caseId) {
const moduleContainer = document.getElementById('case-tags-module');
if (!moduleContainer || !caseId) return;
try {
let tags = [];
const genericResponse = await fetch(`/api/v1/tags/entity/case/${caseId}`, { credentials: 'include' });
if (genericResponse.ok) {
const genericRows = await genericResponse.json();
tags = Array.isArray(genericRows)
? genericRows.map((row) => ({
id: row.id,
name: row.name,
color: row.color,
icon: row.icon,
source: 'generic',
}))
: [];
}
if (!Array.isArray(tags) || tags.length === 0) {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags`, { credentials: 'include' });
if (legacyResponse.ok) {
const legacyRows = await legacyResponse.json();
tags = (Array.isArray(legacyRows) ? legacyRows : []).map((row) => ({
id: row.id || row.tag_id,
name: row.tag_navn || row.name || 'Tag',
color: row.color || '#0f4c75',
icon: row.icon || 'bi-tag',
source: 'legacy',
}));
}
}
if (!Array.isArray(tags) || tags.length === 0) {
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paa sagen endnu</div>';
return;
}
moduleContainer.innerHTML = tags.map((tag) => `
<span class="badge me-1 mb-1 ${isTemplateTriggerName(tag.name) ? 'case-template-tag-trigger' : ''}" data-tag-name="${escapeHtml(tag.name)}" style="background-color: ${tag.color || '#0f4c75'}; ${isTemplateTriggerName(tag.name) ? 'cursor: pointer;' : ''}">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
${tag.id ? `<button type="button" class="btn-close btn-close-white btn-sm ms-1 case-tag-remove-btn"
data-tag-id="${Number(tag.id)}"
data-tag-source="${escapeHtml(tag.source || 'generic')}"
style="font-size: 0.6rem; vertical-align: middle;"
title="Fjern tag"></button>` : ''}
</span>
`).join('');
moduleContainer.querySelectorAll('.case-tag-remove-btn').forEach((btn) => {
btn.onclick = async function (event) {
event.preventDefault();
event.stopPropagation();
const tagId = Number(btn.getAttribute('data-tag-id') || 0);
if (!tagId) return;
const tagSource = String(btn.getAttribute('data-tag-source') || 'generic');
try {
const tryGenericDelete = async () => {
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/${tagId}`, {
method: 'DELETE',
credentials: 'include',
});
return response.ok;
};
const tryLegacyDelete = async () => {
const response = await fetch(`/api/v1/sag/${caseId}/tags/${tagId}`, {
method: 'DELETE',
credentials: 'include',
});
return response.ok;
};
let deleted = false;
if (tagSource === 'legacy') {
deleted = await tryLegacyDelete();
if (!deleted) deleted = await tryGenericDelete();
} else {
deleted = await tryGenericDelete();
if (!deleted) deleted = await tryLegacyDelete();
}
if (!deleted) {
throw new Error('Ingen slet-endpoint accepterede tagget');
}
await loadCaseTagsFallback(caseId);
} catch (error) {
console.error('Case fallback tag delete failed:', error);
alert('Fejl ved sletning af tag: ' + (error && error.message ? error.message : 'ukendt fejl'));
}
};
});
moduleContainer.querySelectorAll('.case-template-tag-trigger').forEach((el) => {
const tagName = String(el.getAttribute('data-tag-name') || '');
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('title', 'Klik for at vaelge opgave-template');
const openSelector = () => {
if (!isTemplateTriggerName(tagName)) return;
if (typeof window.openTaskTemplateSelectorModal !== 'function') return;
window.openTaskTemplateSelectorModal({
entity_type: 'case',
entity_id: caseId,
});
};
el.onclick = openSelector;
el.onkeydown = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openSelector();
}
};
});
} catch (error) {
console.error('Case tags fallback failed:', error);
moduleContainer.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning af tags</div>';
}
}
function wireTagsPlusButton(caseId) {
const tagsCard = document.querySelector('.right-module-card[data-module="tags"]');
const plusBtn = tagsCard ? tagsCard.querySelector('button.btn.btn-sm.btn-outline-primary') : null;
if (!plusBtn || !caseId) return;
plusBtn.onclick = function (event) {
event.preventDefault();
event.stopPropagation();
if (typeof window.showTagPicker !== 'function') return false;
window.showTagPicker('case', caseId, async function () {
await loadCaseTagsFallback(caseId);
});
return false;
};
}
let initialized = false;
function initCaseTagsFallback() {
if (initialized) return;
initialized = true;
const caseId = getCaseIdFromPath();
if (!caseId) return;
loadCaseTagsFallback(caseId);
wireTagsPlusButton(caseId);
if (typeof window.wireCaseTagTemplateClicks === 'function') {
window.wireCaseTagTemplateClicks(document.getElementById('case-tags-module'));
}
if (typeof window.setTagPickerContext === 'function') {
window.setTagPickerContext('case', caseId, async function () {
await loadCaseTagsFallback(caseId);
});
}
window.__caseTagsFallbackReady = true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCaseTagsFallback, { once: true });
} else {
initCaseTagsFallback();
}
window.addEventListener('pageshow', initCaseTagsFallback, { once: true });
})();
</script>
<!-- ── Todo steps resilient bootstrap ────────────────────────────────── -->
<script>
(function () {
function getCaseIdFromPath() {
const match = String(window.location.pathname || '').match(/\/sag\/(\d+)/);
return match ? Number(match[1]) : null;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderFallbackTodoSteps(steps) {
const list = document.getElementById('todo-steps-list');
if (!list) return;
if (!Array.isArray(steps) || steps.length === 0) {
list.innerHTML = '<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>';
return;
}
list.innerHTML = steps.map((step) => {
const title = escapeHtml(step.title || 'Uden titel');
const description = step.description ? `<div class="small text-muted">${escapeHtml(step.description)}</div>` : '';
const due = step.due_date ? escapeHtml(step.due_date) : '-';
const statusBadge = step.is_done
? '<span class="badge bg-success">Faerdig</span>'
: '<span class="badge bg-warning text-dark">Aaben</span>';
return `
<div class="list-group-item todo-step-item">
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="fw-semibold">${title}</div>
${statusBadge}
</div>
${description}
<div class="small text-muted mt-1">Forfald: ${due}</div>
</div>
`;
}).join('');
}
async function loadTodoStepsFallback() {
const list = document.getElementById('todo-steps-list');
const caseId = getCaseIdFromPath();
if (!list || !caseId) return;
if (typeof window.loadTodoSteps === 'function') {
try {
await window.loadTodoSteps();
return;
} catch (error) {
console.warn('Primary loadTodoSteps failed, using fallback:', error);
}
}
list.innerHTML = '<div class="p-3 text-center text-muted">Henter opgaver...</div>';
try {
const response = await fetch(`/api/v1/sag/${caseId}/todo-steps`, { credentials: 'include' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const steps = await response.json();
renderFallbackTodoSteps(steps || []);
} catch (error) {
console.error('Todo fallback load failed:', error);
list.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning</div>';
}
}
function initTodoStepsFallback() {
loadTodoStepsFallback();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTodoStepsFallback, { once: true });
} else {
initTodoStepsFallback();
}
window.addEventListener('pageshow', initTodoStepsFallback, { once: true });
})();
</script>
<!-- ── Beskrivelse inline editor ───────────────────────────────────────── -->
<script>
(function () {

View File

@ -0,0 +1 @@
"""Task templates backend package."""

View File

@ -0,0 +1,97 @@
"""Pydantic models for task template APIs."""
from datetime import date, datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field
TemplateType = Literal["global", "company", "internal", "deactivated"]
TemplateCategory = Literal[
"onboarding",
"offboarding",
"simkort",
"hardwarebestilling",
"brugerandring",
"andet",
]
TemplateItemType = Literal["task", "subcase"]
RunMode = Literal["tasks", "subcases", "combined"]
AssigneeMode = Literal["template_default", "specific_user", "specific_role"]
class TaskTemplateBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
template_type: TemplateType = "global"
customer_id: Optional[int] = None
category: TemplateCategory = "andet"
is_active: bool = True
class TaskTemplateCreate(TaskTemplateBase):
pass
class TaskTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
template_type: Optional[TemplateType] = None
customer_id: Optional[int] = None
category: Optional[TemplateCategory] = None
is_active: Optional[bool] = None
class TaskTemplate(TaskTemplateBase):
id: int
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
class TaskTemplateItemBase(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
item_type: TemplateItemType = "task"
default_assignee_user_id: Optional[int] = None
default_assignee_role_id: Optional[int] = None
days_offset: Optional[int] = 0
sort_order: int = 0
is_required: bool = True
is_active: bool = True
class TaskTemplateItemCreate(TaskTemplateItemBase):
pass
class TaskTemplateItemUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
item_type: Optional[TemplateItemType] = None
default_assignee_user_id: Optional[int] = None
default_assignee_role_id: Optional[int] = None
days_offset: Optional[int] = None
sort_order: Optional[int] = None
is_required: Optional[bool] = None
is_active: Optional[bool] = None
class TaskTemplateItem(TaskTemplateItemBase):
id: int
template_id: int
created_at: datetime
updated_at: datetime
class TemplatePreviewRequest(BaseModel):
template_id: int
start_date: Optional[date] = None
mode: RunMode = "combined"
assignee_mode: AssigneeMode = "template_default"
assignee_user_id: Optional[int] = None
assignee_role_id: Optional[int] = None
class TemplateRunRequest(TemplatePreviewRequest):
pass

View File

@ -0,0 +1,664 @@
"""Task template backend API (MVP)."""
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from app.core.auth_dependencies import require_permission
from app.core.database import execute_query, execute_query_single
from app.modules.task_templates.backend.models import (
TaskTemplate,
TaskTemplateCreate,
TaskTemplateItem,
TaskTemplateItemCreate,
TaskTemplateItemUpdate,
TaskTemplateUpdate,
TemplatePreviewRequest,
TemplateRunRequest,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _validate_company_template_payload(template_type: str, customer_id: Optional[int]) -> None:
if template_type == "company" and not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required for company templates")
if template_type in ("global", "internal", "deactivated") and customer_id is not None:
raise HTTPException(
status_code=400,
detail="customer_id must be null unless template_type is 'company'",
)
def _fetch_case(case_id: int) -> Dict[str, Any]:
case_row = execute_query_single(
"""
SELECT id, titel, customer_id, ansvarlig_bruger_id
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(case_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
return case_row
def _fetch_template_for_case(template_id: int, case_customer_id: Optional[int]) -> Dict[str, Any]:
template = execute_query_single(
"""
SELECT t.*
FROM task_templates t
WHERE t.id = %s
AND t.deleted_at IS NULL
AND t.is_active = TRUE
AND t.template_type != 'deactivated'
AND (
t.template_type IN ('global', 'internal')
OR (t.template_type = 'company' AND t.customer_id = %s)
)
""",
(template_id, case_customer_id),
)
if not template:
raise HTTPException(status_code=404, detail="Template not available for this case")
return template
def _fetch_template_items(template_id: int) -> List[Dict[str, Any]]:
return execute_query(
"""
SELECT *
FROM task_template_items
WHERE template_id = %s
AND is_active = TRUE
ORDER BY sort_order ASC, id ASC
""",
(template_id,),
) or []
def _resolve_assignee(
item: Dict[str, Any],
assignee_mode: str,
assignee_user_id: Optional[int],
assignee_role_id: Optional[int],
) -> Tuple[Optional[int], Optional[int]]:
if assignee_mode == "specific_user":
return assignee_user_id, None
if assignee_mode == "specific_role":
return None, assignee_role_id
return item.get("default_assignee_user_id"), item.get("default_assignee_role_id")
def _build_preview(
items: List[Dict[str, Any]],
start_date: date,
mode: str,
assignee_mode: str,
assignee_user_id: Optional[int],
assignee_role_id: Optional[int],
) -> Dict[str, Any]:
preview_items: List[Dict[str, Any]] = []
summary = {
"subcases": 0,
"tasks": 0,
"assignments": 0,
"deadlines": 0,
}
for item in items:
item_type = item.get("item_type")
if mode == "tasks" and item_type != "task":
continue
if mode == "subcases" and item_type != "subcase":
continue
days_offset = int(item.get("days_offset") or 0)
due_date = start_date + timedelta(days=days_offset)
assigned_user_id, assigned_role_id = _resolve_assignee(
item,
assignee_mode,
assignee_user_id,
assignee_role_id,
)
preview_items.append(
{
"template_item_id": item.get("id"),
"title": item.get("title"),
"description": item.get("description"),
"item_type": item_type,
"sort_order": item.get("sort_order") or 0,
"days_offset": days_offset,
"planned_due_date": due_date.isoformat(),
"is_required": bool(item.get("is_required")),
"assigned_user_id": assigned_user_id,
"assigned_role_id": assigned_role_id,
}
)
if item_type == "subcase":
summary["subcases"] += 1
else:
summary["tasks"] += 1
if assigned_user_id or assigned_role_id:
summary["assignments"] += 1
summary["deadlines"] += 1
return {"items": preview_items, "summary": summary}
@router.get("/task-templates", response_model=List[TaskTemplate])
async def list_task_templates(
company_id: Optional[int] = Query(None),
category: Optional[str] = Query(None),
source: str = Query("all", description="all|company|global|internal"),
current_user: dict = Depends(require_permission("templates.view")),
):
del current_user
where_parts = ["t.deleted_at IS NULL", "t.is_active = TRUE", "t.template_type != 'deactivated'"]
params: List[Any] = []
if category:
where_parts.append("t.category = %s")
params.append(category)
if source == "company":
where_parts.append("t.template_type = 'company'")
elif source == "global":
where_parts.append("t.template_type = 'global'")
elif source == "internal":
where_parts.append("t.template_type = 'internal'")
elif source != "all":
raise HTTPException(status_code=400, detail="Invalid source. Use all|company|global|internal")
if company_id is not None:
where_parts.append(
"(t.template_type IN ('global', 'internal') OR (t.template_type = 'company' AND t.customer_id = %s))"
)
params.append(company_id)
where_clause = " AND ".join(where_parts)
query = f"""
SELECT t.id, t.name, t.description, t.template_type, t.customer_id,
t.category, t.is_active, t.created_by, t.created_at, t.updated_at
FROM task_templates t
WHERE {where_clause}
ORDER BY
CASE
WHEN %s IS NOT NULL AND t.template_type = 'company' AND t.customer_id = %s THEN 0
WHEN t.template_type = 'company' THEN 1
WHEN t.template_type = 'global' THEN 2
ELSE 3
END,
t.name ASC
"""
params.extend([company_id, company_id])
rows = execute_query(query, tuple(params)) or []
return [TaskTemplate(**row) for row in rows]
@router.post("/task-templates", response_model=TaskTemplate)
async def create_task_template(
payload: TaskTemplateCreate,
current_user: dict = Depends(require_permission("templates.create")),
):
_validate_company_template_payload(payload.template_type, payload.customer_id)
created = execute_query_single(
"""
INSERT INTO task_templates (name, description, template_type, customer_id, category, is_active, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, description, template_type, customer_id, category, is_active, created_by, created_at, updated_at
""",
(
payload.name.strip(),
payload.description,
payload.template_type,
payload.customer_id,
payload.category,
payload.is_active,
current_user.get("id"),
),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to create task template")
return TaskTemplate(**created)
@router.patch("/task-templates/{template_id}", response_model=TaskTemplate)
async def update_task_template(
template_id: int,
payload: TaskTemplateUpdate,
current_user: dict = Depends(require_permission("templates.edit")),
):
del current_user
current = execute_query_single(
"SELECT * FROM task_templates WHERE id = %s AND deleted_at IS NULL",
(template_id,),
)
if not current:
raise HTTPException(status_code=404, detail="Template not found")
new_template_type = payload.template_type or current.get("template_type")
if new_template_type in ("global", "internal", "deactivated"):
new_customer_id = None
elif payload.customer_id is not None:
new_customer_id = payload.customer_id
else:
new_customer_id = current.get("customer_id")
_validate_company_template_payload(new_template_type, new_customer_id)
updates = []
params: List[Any] = []
if payload.name is not None:
updates.append("name = %s")
params.append(payload.name.strip())
if payload.description is not None:
updates.append("description = %s")
params.append(payload.description)
if payload.template_type is not None:
updates.append("template_type = %s")
params.append(payload.template_type)
if payload.customer_id is not None or payload.template_type in ("global", "internal", "deactivated"):
updates.append("customer_id = %s")
params.append(new_customer_id)
if payload.category is not None:
updates.append("category = %s")
params.append(payload.category)
if payload.is_active is not None:
updates.append("is_active = %s")
params.append(payload.is_active)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
params.append(template_id)
updated = execute_query_single(
f"""
UPDATE task_templates
SET {', '.join(updates)}
WHERE id = %s AND deleted_at IS NULL
RETURNING id, name, description, template_type, customer_id, category, is_active, created_by, created_at, updated_at
""",
tuple(params),
)
if not updated:
raise HTTPException(status_code=404, detail="Template not found")
return TaskTemplate(**updated)
@router.delete("/task-templates/{template_id}")
async def deactivate_task_template(
template_id: int,
current_user: dict = Depends(require_permission("templates.delete")),
):
del current_user
row = execute_query_single(
"""
UPDATE task_templates
SET is_active = FALSE,
template_type = 'deactivated',
customer_id = NULL,
updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL
RETURNING id
""",
(template_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Template not found")
return {"status": "deactivated", "template_id": template_id}
@router.get("/task-templates/{template_id}/items", response_model=List[TaskTemplateItem])
async def list_task_template_items(
template_id: int,
current_user: dict = Depends(require_permission("templates.view")),
):
del current_user
rows = _fetch_template_items(template_id)
return [TaskTemplateItem(**row) for row in rows]
@router.post("/task-templates/{template_id}/items", response_model=TaskTemplateItem)
async def create_task_template_item(
template_id: int,
payload: TaskTemplateItemCreate,
current_user: dict = Depends(require_permission("templates.edit")),
):
del current_user
template_exists = execute_query_single(
"SELECT id FROM task_templates WHERE id = %s AND deleted_at IS NULL",
(template_id,),
)
if not template_exists:
raise HTTPException(status_code=404, detail="Template not found")
created = execute_query_single(
"""
INSERT INTO task_template_items (
template_id, title, description, item_type, default_assignee_user_id,
default_assignee_role_id, days_offset, sort_order, is_required, is_active
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, template_id, title, description, item_type, default_assignee_user_id,
default_assignee_role_id, days_offset, sort_order, is_required, is_active,
created_at, updated_at
""",
(
template_id,
payload.title.strip(),
payload.description,
payload.item_type,
payload.default_assignee_user_id,
payload.default_assignee_role_id,
payload.days_offset,
payload.sort_order,
payload.is_required,
payload.is_active,
),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to create template item")
return TaskTemplateItem(**created)
@router.patch("/task-template-items/{item_id}", response_model=TaskTemplateItem)
async def update_task_template_item(
item_id: int,
payload: TaskTemplateItemUpdate,
current_user: dict = Depends(require_permission("templates.edit")),
):
del current_user
updates = []
params: List[Any] = []
if payload.title is not None:
updates.append("title = %s")
params.append(payload.title.strip())
if payload.description is not None:
updates.append("description = %s")
params.append(payload.description)
if payload.item_type is not None:
updates.append("item_type = %s")
params.append(payload.item_type)
if payload.default_assignee_user_id is not None:
updates.append("default_assignee_user_id = %s")
params.append(payload.default_assignee_user_id)
if payload.default_assignee_role_id is not None:
updates.append("default_assignee_role_id = %s")
params.append(payload.default_assignee_role_id)
if payload.days_offset is not None:
updates.append("days_offset = %s")
params.append(payload.days_offset)
if payload.sort_order is not None:
updates.append("sort_order = %s")
params.append(payload.sort_order)
if payload.is_required is not None:
updates.append("is_required = %s")
params.append(payload.is_required)
if payload.is_active is not None:
updates.append("is_active = %s")
params.append(payload.is_active)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
params.append(item_id)
row = execute_query_single(
f"""
UPDATE task_template_items
SET {', '.join(updates)}
WHERE id = %s
RETURNING id, template_id, title, description, item_type, default_assignee_user_id,
default_assignee_role_id, days_offset, sort_order, is_required, is_active,
created_at, updated_at
""",
tuple(params),
)
if not row:
raise HTTPException(status_code=404, detail="Template item not found")
return TaskTemplateItem(**row)
@router.post("/cases/{case_id}/template-preview")
async def preview_template_for_case(
case_id: int,
payload: TemplatePreviewRequest,
current_user: dict = Depends(require_permission("templates.view")),
):
del current_user
case_row = _fetch_case(case_id)
template = _fetch_template_for_case(payload.template_id, case_row.get("customer_id"))
start_date = payload.start_date or date.today()
items = _fetch_template_items(payload.template_id)
preview = _build_preview(
items,
start_date,
payload.mode,
payload.assignee_mode,
payload.assignee_user_id,
payload.assignee_role_id,
)
return {
"case_id": case_id,
"template": {
"id": template.get("id"),
"name": template.get("name"),
"template_type": template.get("template_type"),
"category": template.get("category"),
},
"mode": payload.mode,
"assignee_mode": payload.assignee_mode,
"start_date": start_date.isoformat(),
"summary": preview["summary"],
"items": preview["items"],
}
@router.post("/cases/{case_id}/run-template")
async def run_template_for_case(
case_id: int,
payload: TemplateRunRequest,
current_user: dict = Depends(require_permission("templates.run")),
):
case_row = _fetch_case(case_id)
template = _fetch_template_for_case(payload.template_id, case_row.get("customer_id"))
start_date = payload.start_date or date.today()
items = _fetch_template_items(payload.template_id)
preview = _build_preview(
items,
start_date,
payload.mode,
payload.assignee_mode,
payload.assignee_user_id,
payload.assignee_role_id,
)
run_row = execute_query_single(
"""
INSERT INTO case_template_runs (case_id, template_id, started_by, started_at, status)
VALUES (%s, %s, %s, NOW(), 'running')
RETURNING id
""",
(case_id, payload.template_id, current_user.get("id")),
)
if not run_row:
raise HTTPException(status_code=500, detail="Failed to start template run")
run_id = run_row.get("id")
created_task_ids: List[int] = []
created_case_ids: List[int] = []
try:
for item in preview["items"]:
created_task_id = None
created_case_id = None
if item["item_type"] == "task":
task_row = execute_query_single(
"""
INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(
case_id,
item["title"],
item.get("description"),
item.get("planned_due_date"),
current_user.get("id"),
),
)
created_task_id = task_row.get("id") if task_row else None
if created_task_id:
created_task_ids.append(created_task_id)
elif item["item_type"] == "subcase":
subcase_row = execute_query_single(
"""
INSERT INTO sag_sager (
titel,
beskrivelse,
template_key,
status,
customer_id,
ansvarlig_bruger_id,
created_by_user_id,
deadline
)
VALUES (%s, %s, %s, 'åben', %s, %s, %s, %s::date + time '09:00')
RETURNING id
""",
(
item["title"],
item.get("description"),
"task_template",
case_row.get("customer_id"),
case_row.get("ansvarlig_bruger_id"),
current_user.get("id"),
item.get("planned_due_date"),
),
)
created_case_id = subcase_row.get("id") if subcase_row else None
if created_case_id:
created_case_ids.append(created_case_id)
execute_query(
"""
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
VALUES (%s, %s, %s)
""",
(case_id, created_case_id, "undersag"),
)
execute_query(
"""
INSERT INTO case_template_run_items (
template_run_id, template_item_id, created_case_id, created_task_id, status
)
VALUES (%s, %s, %s, %s, %s)
""",
(
run_id,
item.get("template_item_id"),
created_case_id,
created_task_id,
"created" if (created_case_id or created_task_id) else "skipped",
),
)
execute_query(
"UPDATE case_template_runs SET status = 'completed', updated_at = NOW() WHERE id = %s",
(run_id,),
)
except Exception as exc:
logger.error("❌ Template run %s failed: %s", run_id, exc)
execute_query(
"UPDATE case_template_runs SET status = 'failed', error_message = %s, updated_at = NOW() WHERE id = %s",
(str(exc), run_id),
)
raise HTTPException(status_code=500, detail="Template run failed")
return {
"status": "completed",
"run_id": run_id,
"case_id": case_id,
"template": {
"id": template.get("id"),
"name": template.get("name"),
},
"summary": preview["summary"],
"created": {
"task_ids": created_task_ids,
"subcase_ids": created_case_ids,
},
"links": {
"original_case_id": case_id,
"related_subcases": created_case_ids,
},
}
@router.get("/cases/{case_id}/template-runs")
async def list_template_runs_for_case(
case_id: int,
limit: int = Query(10, ge=1, le=50),
current_user: dict = Depends(require_permission("templates.view")),
):
del current_user
_fetch_case(case_id)
rows = execute_query(
"""
SELECT
r.id,
r.case_id,
r.template_id,
t.name AS template_name,
r.started_by,
r.started_at,
r.status,
r.error_message,
COUNT(ri.id) AS item_count,
COUNT(ri.id) FILTER (WHERE ri.created_task_id IS NOT NULL) AS created_tasks,
COUNT(ri.id) FILTER (WHERE ri.created_case_id IS NOT NULL) AS created_subcases
FROM case_template_runs r
LEFT JOIN task_templates t ON t.id = r.template_id
LEFT JOIN case_template_run_items ri ON ri.template_run_id = r.id
WHERE r.case_id = %s
GROUP BY r.id, t.name
ORDER BY r.started_at DESC, r.id DESC
LIMIT %s
""",
(case_id, limit),
) or []
return rows

View File

@ -107,6 +107,9 @@
<a class="nav-link" href="#email-templates" data-tab="email-templates">
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
</a>
<a class="nav-link" href="#task-templates" data-tab="task-templates">
<i class="bi bi-list-check me-2"></i>Opgave-templates
</a>
<a class="nav-link" href="/admin/bmc-office-upload">
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
</a>
@ -524,6 +527,104 @@
</div>
</div>
<!-- Task Templates -->
<div class="tab-pane fade" id="task-templates">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-1">Opgave-templates</h5>
<p class="text-muted mb-0">Administrer globale og firma-specifikke templates</p>
</div>
<button class="btn btn-primary" onclick="openTaskTemplateSettingsModal()">
<i class="bi bi-plus-lg me-2"></i>Ny template
</button>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small text-muted">Kilde</label>
<select class="form-select" id="taskTemplateSourceFilter" onchange="loadTaskTemplates()">
<option value="all">Alle</option>
<option value="company">Firma</option>
<option value="global">Faelles</option>
<option value="internal">Intern</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Kategori</label>
<select class="form-select" id="taskTemplateCategoryFilter" onchange="loadTaskTemplates()">
<option value="">Alle</option>
<option value="onboarding">Onboarding</option>
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="andet">Andet</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Kunde</label>
<select class="form-select" id="taskTemplateCompanyFilter" onchange="loadTaskTemplates()">
<option value="">Alle kunder</option>
</select>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th>Navn</th>
<th>Kategori</th>
<th>Type</th>
<th>Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="taskTemplatesTableBody">
<tr>
<td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="fw-bold mb-0" id="taskTemplateItemsTitle">Template-opgaver</h6>
<small class="text-muted" id="taskTemplateItemsSubtitle">Vaelg en template for at redigere items</small>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="taskTemplateAddItemBtn" onclick="openTaskTemplateItemModal()" disabled>
<i class="bi bi-plus-lg me-1"></i>Tilfoej
</button>
<button class="btn btn-sm btn-outline-secondary" id="taskTemplateBulkBuilderBtn" onclick="openTaskTemplateBulkBuilderModal()" disabled>
<i class="bi bi-magic me-1"></i>Builder
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 420px; overflow:auto;">
<div id="taskTemplateItemsList" class="list-group list-group-flush">
<div class="list-group-item text-muted small">Ingen template valgt.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Users & Groups -->
<div class="tab-pane fade" id="users">
<div class="row g-4">
@ -3856,6 +3957,9 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'task-templates') {
loadTaskTemplateCustomers();
loadTaskTemplates();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'mission') {
@ -4882,6 +4986,714 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
</div>
<!-- Task Template Modal -->
<div class="modal fade" id="taskTemplateModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="taskTemplateModalTitle">Opret opgave-template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="taskTemplateForm">
<input type="hidden" id="taskTemplateId">
<div class="mb-3">
<label class="form-label">Navn *</label>
<input type="text" id="taskTemplateName" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea id="taskTemplateDescription" class="form-control" rows="3"></textarea>
</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Template-type *</label>
<select id="taskTemplateType" class="form-select" required>
<option value="global">Global</option>
<option value="company">Firma</option>
<option value="internal">Intern</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Kategori *</label>
<select id="taskTemplateCategory" class="form-select" required>
<option value="onboarding">Onboarding</option>
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="andet" selected>Andet</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Firma (kun firma-type)</label>
<select id="taskTemplateCompany" class="form-select">
<option value="">Vaelg firma...</option>
</select>
</div>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="taskTemplateIsActive" checked>
<label class="form-check-label" for="taskTemplateIsActive">Aktiv</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveTaskTemplateSettings()">Gem template</button>
</div>
</div>
</div>
</div>
<!-- Task Template Item Modal -->
<div class="modal fade" id="taskTemplateItemModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="taskTemplateItemModalTitle">Tilfoej item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="taskTemplateItemForm">
<input type="hidden" id="taskTemplateItemId">
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" id="taskTemplateItemTitle" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea id="taskTemplateItemDescription" class="form-control" rows="2"></textarea>
</div>
<div class="row g-3">
<div class="col-6">
<label class="form-label">Type</label>
<select id="taskTemplateItemType" class="form-select">
<option value="task">Task</option>
<option value="subcase">Undersag</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Sortering</label>
<input type="number" id="taskTemplateItemSortOrder" class="form-control" value="0" step="1">
</div>
<div class="col-6">
<label class="form-label">Dage offset</label>
<input type="number" id="taskTemplateItemDaysOffset" class="form-control" value="0" step="1">
</div>
<div class="col-6">
<label class="form-label">Standard ansvarlig (user_id)</label>
<input type="number" id="taskTemplateItemDefaultUser" class="form-control" min="1" step="1">
</div>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="taskTemplateItemRequired" checked>
<label class="form-check-label" for="taskTemplateItemRequired">Obligatorisk</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="taskTemplateItemActive" checked>
<label class="form-check-label" for="taskTemplateItemActive">Aktiv</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveTaskTemplateItem()">Gem item</button>
</div>
</div>
</div>
</div>
<!-- Task Template Builder Modal -->
<div class="modal fade" id="taskTemplateBulkBuilderModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Template Builder (bulk)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="fillTaskTemplateBuilderPreset('onboarding')">Onboarding preset</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="fillTaskTemplateBuilderPreset('offboarding')">Offboarding preset</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="fillTaskTemplateBuilderPreset('hardware')">Hardware preset</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="clearTaskTemplateBuilderInput()">Ryd</button>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Standard type</label>
<select id="taskTemplateBulkDefaultType" class="form-select">
<option value="task" selected>Task</option>
<option value="subcase">Undersag</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Standard dage offset</label>
<input type="number" id="taskTemplateBulkDefaultOffset" class="form-control" value="0" step="1">
</div>
<div class="col-md-4">
<label class="form-label">Start sortering</label>
<input type="number" id="taskTemplateBulkStartSort" class="form-control" value="10" step="1">
</div>
</div>
<div class="mb-2 small text-muted">
En linje per item. Formater:
<div><code>task|0|Titel|Beskrivelse</code></div>
<div><code>subcase|2|Titel|Beskrivelse</code></div>
<div><code>Titel alene</code> (bruger standard type/offset)</div>
</div>
<textarea id="taskTemplateBulkLines" class="form-control font-monospace" rows="12" placeholder="task|0|Velkomstmail|Send velkomstmail til brugeren"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="importTaskTemplateBulkItems()">Importer items</button>
</div>
</div>
</div>
</div>
<script>
let taskTemplatesCache = [];
let taskTemplateCustomersCache = [];
let selectedTaskTemplateId = null;
const TASK_TEMPLATE_BUILDER_PRESETS = {
onboarding: [
'task|0|Velkomstmail til bruger|Send velkomstmail med kontaktinfo og forventet plan',
'task|0|Bestil hardware|Bestil standard udstyr til ny medarbejder',
'task|1|Klargoer kontoer og licenser|Opsaet M365, VPN, sikkerhedsprofil og grupper',
'task|2|Planlaeg introduktionsmoede|Book intro med leder og support',
'subcase|0|Onboarding undersag: adgange|Opfoelgning paa adgangsrequests og godkendelser'
],
offboarding: [
'task|0|Bekraeft fratraedelsesdato|Afstem sidste arbejdsdag med leder/HR',
'task|0|Luk adgange|Deaktiver M365, VPN og eksterne konti',
'task|1|Indsaml udstyr|Koordiner retur af laptop, mobil og noegler',
'subcase|0|Offboarding undersag: dokumentation|Saml audit-noter og afslutningsdokumentation'
],
hardware: [
'task|0|Afdaek behov|Afklar krav til model, tilbehoer og levering',
'task|0|Indhent pris|Kontroller pris og leveringstid hos leverandoer',
'task|1|Bestil hardware|Placer ordren og registrer ordrenummer',
'task|3|Levering og klargoering|Klargoer enhed og informer bruger'
]
};
function getTaskTemplateBuilderModal() {
const el = document.getElementById('taskTemplateBulkBuilderModal');
return bootstrap.Modal.getOrCreateInstance(el);
}
function openTaskTemplateBulkBuilderModal() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
return;
}
const linesInput = document.getElementById('taskTemplateBulkLines');
const startSortInput = document.getElementById('taskTemplateBulkStartSort');
if (linesInput && !linesInput.value.trim()) {
linesInput.value = TASK_TEMPLATE_BUILDER_PRESETS.onboarding.join('\n');
}
if (startSortInput) {
startSortInput.value = '10';
}
getTaskTemplateBuilderModal().show();
}
function fillTaskTemplateBuilderPreset(presetKey) {
const lines = TASK_TEMPLATE_BUILDER_PRESETS[presetKey] || [];
const input = document.getElementById('taskTemplateBulkLines');
if (!input) return;
input.value = lines.join('\n');
}
function clearTaskTemplateBuilderInput() {
const input = document.getElementById('taskTemplateBulkLines');
if (!input) return;
input.value = '';
}
function parseTaskTemplateBuilderLines(rawText, defaults = {}) {
const rows = String(rawText || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const defaultType = defaults.defaultType === 'subcase' ? 'subcase' : 'task';
const defaultOffset = Number.isFinite(defaults.defaultOffset) ? defaults.defaultOffset : 0;
let currentSort = Number.isFinite(defaults.startSort) ? defaults.startSort : 10;
const items = [];
for (const row of rows) {
const parts = row.split('|').map((p) => p.trim());
let itemType = defaultType;
let daysOffset = defaultOffset;
let title = '';
let description = null;
if (parts.length === 1) {
title = parts[0];
} else {
const first = String(parts[0] || '').toLowerCase();
let index = 0;
if (first === 'task' || first === 'subcase') {
itemType = first;
index += 1;
}
const maybeOffset = Number(parts[index]);
if (!Number.isNaN(maybeOffset)) {
daysOffset = maybeOffset;
index += 1;
}
title = parts[index] || '';
description = parts.slice(index + 1).join(' | ') || null;
}
if (!title) continue;
items.push({
title,
description,
item_type: itemType,
days_offset: daysOffset,
sort_order: currentSort,
default_assignee_user_id: null,
is_required: true,
is_active: true
});
currentSort += 10;
}
return items;
}
async function importTaskTemplateBulkItems() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
return;
}
const raw = document.getElementById('taskTemplateBulkLines')?.value || '';
const defaultType = document.getElementById('taskTemplateBulkDefaultType')?.value || 'task';
const defaultOffset = parseInt(document.getElementById('taskTemplateBulkDefaultOffset')?.value || '0', 10);
const startSort = parseInt(document.getElementById('taskTemplateBulkStartSort')?.value || '10', 10);
const parsedItems = parseTaskTemplateBuilderLines(raw, {
defaultType,
defaultOffset: Number.isNaN(defaultOffset) ? 0 : defaultOffset,
startSort: Number.isNaN(startSort) ? 10 : startSort,
});
if (!parsedItems.length) {
alert('Ingen gyldige linjer at importere');
return;
}
let created = 0;
let failed = 0;
const errors = [];
for (const payload of parsedItems) {
try {
const response = await fetch(`/api/v1/task-templates/${selectedTaskTemplateId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const message = await getErrorMessage(response, 'Kunne ikke oprette item');
throw new Error(message);
}
created += 1;
} catch (error) {
failed += 1;
errors.push(`${payload.title}: ${error.message || 'Fejl'}`);
}
}
await loadTaskTemplateItems(selectedTaskTemplateId);
if (created > 0 && failed === 0) {
showNotification(`${created} items importeret`, 'success');
getTaskTemplateBuilderModal().hide();
return;
}
if (created > 0) {
showNotification(`${created} items importeret, ${failed} fejlede`, 'warning');
} else {
showNotification('Ingen items blev importeret', 'error');
}
if (errors.length) {
alert(`Import-fejl:\n${errors.slice(0, 8).join('\n')}`);
}
}
function taskTemplateTypeLabel(templateType) {
if (templateType === 'company') return 'Firma';
if (templateType === 'internal') return 'Intern';
if (templateType === 'global') return 'Global';
return templateType || '-';
}
function taskTemplateCategoryLabel(category) {
const labels = {
onboarding: 'Onboarding',
offboarding: 'Offboarding',
simkort: 'Mobil / SIM-kort',
hardwarebestilling: 'Hardwarebestilling',
brugerandring: 'Brugeraendring',
andet: 'Andet'
};
return labels[category] || category || '-';
}
function getTaskTemplateById(templateId) {
return taskTemplatesCache.find((tpl) => Number(tpl.id) === Number(templateId));
}
async function loadTaskTemplateCustomers() {
try {
const response = await fetch('/api/v1/customers');
if (!response.ok) throw new Error('Kunne ikke hente kunder');
const payload = await response.json();
const customers = Array.isArray(payload) ? payload : (payload.customers || []);
taskTemplateCustomersCache = customers;
const filterSelect = document.getElementById('taskTemplateCompanyFilter');
const modalSelect = document.getElementById('taskTemplateCompany');
if (!filterSelect || !modalSelect) return;
filterSelect.innerHTML = '<option value="">Alle kunder</option>';
modalSelect.innerHTML = '<option value="">Vaelg firma...</option>';
customers.forEach((customer) => {
const label = `${customer.name} (#${customer.id})`;
filterSelect.add(new Option(label, customer.id));
modalSelect.add(new Option(label, customer.id));
});
} catch (error) {
console.error('Error loading task template customers:', error);
}
}
async function loadTaskTemplates() {
const tbody = document.getElementById('taskTemplatesTableBody');
if (!tbody) return;
const source = document.getElementById('taskTemplateSourceFilter')?.value || 'all';
const category = document.getElementById('taskTemplateCategoryFilter')?.value || '';
const companyId = document.getElementById('taskTemplateCompanyFilter')?.value || '';
const params = new URLSearchParams();
if (source) params.set('source', source);
if (category) params.set('category', category);
if (companyId) params.set('company_id', companyId);
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-5"><div class="spinner-border text-primary" role="status"></div></td></tr>';
try {
const response = await fetch(`/api/v1/task-templates?${params.toString()}`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke hente templates'));
}
taskTemplatesCache = await response.json();
if (!Array.isArray(taskTemplatesCache) || taskTemplatesCache.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen templates fundet</td></tr>';
return;
}
tbody.innerHTML = taskTemplatesCache.map((template) => `
<tr>
<td>
<div class="fw-semibold">${escapeHtml(template.name || '')}</div>
<small class="text-muted">#${template.id}</small>
</td>
<td>${escapeHtml(taskTemplateCategoryLabel(template.category))}</td>
<td><span class="badge bg-light text-dark border">${escapeHtml(taskTemplateTypeLabel(template.template_type))}</span></td>
<td>${template.is_active ? '<span class="badge bg-success-subtle text-success">Aktiv</span>' : '<span class="badge bg-secondary">Inaktiv</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" onclick="selectTaskTemplate(${template.id})">
<i class="bi bi-list-ul"></i>
</button>
<button class="btn btn-sm btn-outline-primary me-1" onclick="editTaskTemplateSettings(${template.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deactivateTaskTemplateSettings(${template.id})">
<i class="bi bi-slash-circle"></i>
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading task templates:', error);
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(error.message || 'Fejl ved indlaesning')}</td></tr>`;
}
}
function openTaskTemplateSettingsModal() {
document.getElementById('taskTemplateForm').reset();
document.getElementById('taskTemplateId').value = '';
document.getElementById('taskTemplateModalTitle').textContent = 'Opret opgave-template';
document.getElementById('taskTemplateIsActive').checked = true;
const modal = new bootstrap.Modal(document.getElementById('taskTemplateModal'));
modal.show();
}
function editTaskTemplateSettings(templateId) {
const template = getTaskTemplateById(templateId);
if (!template) return;
document.getElementById('taskTemplateId').value = template.id;
document.getElementById('taskTemplateName').value = template.name || '';
document.getElementById('taskTemplateDescription').value = template.description || '';
document.getElementById('taskTemplateType').value = template.template_type || 'global';
document.getElementById('taskTemplateCompany').value = template.customer_id || '';
document.getElementById('taskTemplateCategory').value = template.category || 'andet';
document.getElementById('taskTemplateIsActive').checked = !!template.is_active;
document.getElementById('taskTemplateModalTitle').textContent = 'Rediger opgave-template';
const modal = new bootstrap.Modal(document.getElementById('taskTemplateModal'));
modal.show();
}
async function saveTaskTemplateSettings() {
const templateId = document.getElementById('taskTemplateId').value;
const templateType = document.getElementById('taskTemplateType').value;
const customerIdRaw = document.getElementById('taskTemplateCompany').value;
const customerId = customerIdRaw ? Number(customerIdRaw) : null;
const payload = {
name: document.getElementById('taskTemplateName').value,
description: document.getElementById('taskTemplateDescription').value || null,
template_type: templateType,
customer_id: templateType === 'company' ? customerId : null,
category: document.getElementById('taskTemplateCategory').value,
is_active: document.getElementById('taskTemplateIsActive').checked,
};
if (!payload.name) {
alert('Navn er paakraevet');
return;
}
if (templateType === 'company' && !payload.customer_id) {
alert('Vaelg firma for firma-template');
return;
}
const method = templateId ? 'PATCH' : 'POST';
const url = templateId ? `/api/v1/task-templates/${templateId}` : '/api/v1/task-templates';
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme template'));
}
bootstrap.Modal.getInstance(document.getElementById('taskTemplateModal')).hide();
await loadTaskTemplates();
} catch (error) {
alert(error.message || 'Kunne ikke gemme template');
}
}
async function deactivateTaskTemplateSettings(templateId) {
if (!confirm('Deaktivere denne template?')) return;
try {
const response = await fetch(`/api/v1/task-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke deaktivere template'));
}
if (selectedTaskTemplateId === templateId) {
selectedTaskTemplateId = null;
document.getElementById('taskTemplateItemsTitle').textContent = 'Template-opgaver';
document.getElementById('taskTemplateItemsSubtitle').textContent = 'Vaelg en template for at redigere items';
document.getElementById('taskTemplateItemsList').innerHTML = '<div class="list-group-item text-muted small">Ingen template valgt.</div>';
document.getElementById('taskTemplateAddItemBtn').disabled = true;
document.getElementById('taskTemplateBulkBuilderBtn').disabled = true;
}
await loadTaskTemplates();
} catch (error) {
alert(error.message || 'Kunne ikke deaktivere template');
}
}
async function selectTaskTemplate(templateId) {
selectedTaskTemplateId = templateId;
document.getElementById('taskTemplateAddItemBtn').disabled = false;
document.getElementById('taskTemplateBulkBuilderBtn').disabled = false;
const template = getTaskTemplateById(templateId);
document.getElementById('taskTemplateItemsTitle').textContent = template ? template.name : 'Template-opgaver';
document.getElementById('taskTemplateItemsSubtitle').textContent = template
? `${taskTemplateCategoryLabel(template.category)} · ${taskTemplateTypeLabel(template.template_type)}`
: 'Template-opgaver';
await loadTaskTemplateItems(templateId);
}
async function loadTaskTemplateItems(templateId) {
const container = document.getElementById('taskTemplateItemsList');
if (!container) return;
container.innerHTML = '<div class="list-group-item text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
try {
const response = await fetch(`/api/v1/task-templates/${templateId}/items`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke hente template-items'));
}
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) {
container.innerHTML = '<div class="list-group-item text-muted small">Ingen items endnu.</div>';
return;
}
container.innerHTML = items.map((item) => `
<div class="list-group-item">
<div class="d-flex justify-content-between gap-2">
<div>
<div class="fw-semibold">${escapeHtml(item.title || '')}</div>
<div class="small text-muted">${escapeHtml(item.description || '')}</div>
<div class="small text-muted mt-1">
${item.item_type === 'subcase' ? 'Undersag' : 'Task'} ·
offset: ${Number(item.days_offset || 0)} dage ·
sortering: ${Number(item.sort_order || 0)}
</div>
</div>
<div class="text-end">
<button class="btn btn-sm btn-outline-primary js-edit-task-template-item" data-item-id="${item.id}">
<i class="bi bi-pencil"></i>
</button>
</div>
</div>
</div>
`).join('');
const editButtons = container.querySelectorAll('.js-edit-task-template-item');
editButtons.forEach((button) => {
button.addEventListener('click', () => {
const itemId = Number(button.dataset.itemId || 0);
const item = items.find((entry) => Number(entry.id) === itemId);
if (item) editTaskTemplateItem(item);
});
});
} catch (error) {
container.innerHTML = `<div class="list-group-item text-danger small">${escapeHtml(error.message || 'Fejl')}</div>`;
}
}
function openTaskTemplateItemModal() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
return;
}
document.getElementById('taskTemplateItemForm').reset();
document.getElementById('taskTemplateItemId').value = '';
document.getElementById('taskTemplateItemModalTitle').textContent = 'Tilfoej item';
document.getElementById('taskTemplateItemRequired').checked = true;
document.getElementById('taskTemplateItemActive').checked = true;
const modal = new bootstrap.Modal(document.getElementById('taskTemplateItemModal'));
modal.show();
}
function editTaskTemplateItem(item) {
if (!item) return;
document.getElementById('taskTemplateItemId').value = item.id;
document.getElementById('taskTemplateItemTitle').value = item.title || '';
document.getElementById('taskTemplateItemDescription').value = item.description || '';
document.getElementById('taskTemplateItemType').value = item.item_type || 'task';
document.getElementById('taskTemplateItemSortOrder').value = Number(item.sort_order || 0);
document.getElementById('taskTemplateItemDaysOffset').value = Number(item.days_offset || 0);
document.getElementById('taskTemplateItemDefaultUser').value = item.default_assignee_user_id || '';
document.getElementById('taskTemplateItemRequired').checked = !!item.is_required;
document.getElementById('taskTemplateItemActive').checked = !!item.is_active;
document.getElementById('taskTemplateItemModalTitle').textContent = 'Rediger item';
const modal = new bootstrap.Modal(document.getElementById('taskTemplateItemModal'));
modal.show();
}
async function saveTaskTemplateItem() {
if (!selectedTaskTemplateId) {
alert('Vaelg en template foerst');
return;
}
const itemId = document.getElementById('taskTemplateItemId').value;
const payload = {
title: document.getElementById('taskTemplateItemTitle').value,
description: document.getElementById('taskTemplateItemDescription').value || null,
item_type: document.getElementById('taskTemplateItemType').value,
days_offset: parseInt(document.getElementById('taskTemplateItemDaysOffset').value || '0', 10),
sort_order: parseInt(document.getElementById('taskTemplateItemSortOrder').value || '0', 10),
default_assignee_user_id: document.getElementById('taskTemplateItemDefaultUser').value
? Number(document.getElementById('taskTemplateItemDefaultUser').value)
: null,
is_required: document.getElementById('taskTemplateItemRequired').checked,
is_active: document.getElementById('taskTemplateItemActive').checked,
};
if (!payload.title) {
alert('Titel er paakraevet');
return;
}
const method = itemId ? 'PATCH' : 'POST';
const url = itemId
? `/api/v1/task-template-items/${itemId}`
: `/api/v1/task-templates/${selectedTaskTemplateId}/items`;
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme item'));
}
bootstrap.Modal.getInstance(document.getElementById('taskTemplateItemModal')).hide();
await loadTaskTemplateItems(selectedTaskTemplateId);
} catch (error) {
alert(error.message || 'Kunne ikke gemme item');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTaskTemplateCustomers();
});
</script>
<!-- Email Template Modal -->
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
<div class="modal-dialog modal-lg">

View File

@ -1199,7 +1199,8 @@ window.addEventListener('unhandledrejection', function(event) {
});
</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.1"></script>
<script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/task-template-selector.js?v=1.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>

View File

@ -78,6 +78,7 @@ class EntityTag(EntityTagBase):
id: int
tagged_by: Optional[int]
tagged_at: datetime
action: Optional[dict] = None
class Config:
from_attributes = True

View File

@ -207,6 +207,28 @@ def _tag_row_to_response(row: dict) -> dict:
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
return out
def _resolve_tag_action_for_add(tag_id: int) -> Optional[dict]:
workflow = execute_query_single(
"""
SELECT action_type, COALESCE(action_config, '{}'::jsonb) AS action_config
FROM tag_workflows
WHERE tag_id = %s
AND trigger_event = 'on_add'
AND is_active = TRUE
ORDER BY id DESC
LIMIT 1
""",
(tag_id,),
)
if not workflow:
return None
return {
"type": workflow.get("action_type"),
"config": workflow.get("action_config") or {},
}
# ============= TAG GROUPS =============
@router.get("/groups", response_model=List[TagGroup])
@ -514,6 +536,12 @@ async def add_tag_to_entity(entity_tag: EntityTagCreate):
)
if not result:
raise HTTPException(status_code=409, detail="Tag already exists on entity")
action = _resolve_tag_action_for_add(entity_tag.tag_id)
if action:
result = dict(result)
result["action"] = action
return result
@router.delete("/entity")

View File

@ -137,6 +137,7 @@ from app.modules.manual.frontend import views as manual_views
from app.modules.bottom_bar.backend import router as bottom_bar_api
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
from app.modules.rentals.backend import router as rentals_api
from app.modules.task_templates.backend import router as task_templates_api
# Configure logging
logging.basicConfig(
@ -454,6 +455,7 @@ app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api

View File

@ -0,0 +1,125 @@
-- Task template MVP for case automation
CREATE TABLE IF NOT EXISTS task_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
template_type VARCHAR(20) NOT NULL DEFAULT 'global'
CHECK (template_type IN ('global', 'company', 'internal', 'deactivated')),
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
category VARCHAR(50) NOT NULL DEFAULT 'andet'
CHECK (category IN ('onboarding', 'offboarding', 'simkort', 'hardwarebestilling', 'brugerandring', 'andet')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
CONSTRAINT task_templates_company_type_consistency CHECK (
(template_type = 'company' AND customer_id IS NOT NULL)
OR (template_type IN ('global', 'internal', 'deactivated') AND customer_id IS NULL)
)
);
CREATE TABLE IF NOT EXISTS task_template_items (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES task_templates(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('task', 'subcase')),
default_assignee_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
default_assignee_role_id INTEGER,
days_offset INTEGER DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
is_required BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS case_template_runs (
id SERIAL PRIMARY KEY,
case_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
template_id INTEGER NOT NULL REFERENCES task_templates(id) ON DELETE RESTRICT,
started_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
status VARCHAR(30) NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'completed', 'failed')),
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS case_template_run_items (
id SERIAL PRIMARY KEY,
template_run_id INTEGER NOT NULL REFERENCES case_template_runs(id) ON DELETE CASCADE,
template_item_id INTEGER REFERENCES task_template_items(id) ON DELETE SET NULL,
created_case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
created_task_id INTEGER REFERENCES sag_todo_steps(id) ON DELETE SET NULL,
status VARCHAR(30) NOT NULL DEFAULT 'created'
CHECK (status IN ('created', 'skipped', 'failed')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_task_templates_active
ON task_templates (is_active)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_task_templates_customer
ON task_templates (customer_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_task_templates_type
ON task_templates (template_type)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_task_template_items_template
ON task_template_items (template_id, sort_order, id)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_case_template_runs_case
ON case_template_runs (case_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_case_template_run_items_run
ON case_template_run_items (template_run_id);
INSERT INTO permissions (code, description, category)
VALUES
('templates.view', 'View task templates', 'templates'),
('templates.create', 'Create task templates', 'templates'),
('templates.edit', 'Edit task templates', 'templates'),
('templates.delete', 'Deactivate task templates', 'templates'),
('templates.run', 'Run task templates on cases', 'templates')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
JOIN permissions p ON p.code IN (
'templates.view',
'templates.create',
'templates.edit',
'templates.delete',
'templates.run'
)
WHERE g.name = 'Administrators'
ON CONFLICT DO NOTHING;
INSERT INTO tags (name, type, description, color, is_active)
VALUES ('opgave_template', 'workflow', 'Åbner opgave-template modal', '#ff6b35', TRUE)
ON CONFLICT (name, type) DO NOTHING;
INSERT INTO tag_workflows (tag_id, trigger_event, action_type, action_config, is_active)
SELECT t.id, 'on_add', 'open_task_template_modal',
'{"modal":"task_template_selector","default_category":null,"allow_company_templates":true,"allow_global_templates":true}'::jsonb,
TRUE
FROM tags t
WHERE t.name = 'opgave_template' AND t.type = 'workflow'
AND NOT EXISTS (
SELECT 1
FROM tag_workflows tw
WHERE tw.tag_id = t.id
AND tw.trigger_event = 'on_add'
AND tw.action_type = 'open_task_template_modal'
AND tw.is_active = TRUE
);

View File

@ -260,6 +260,7 @@ class TagPicker {
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
// If context provided, add tag to entity
let addResult = null;
if (this.contextType && this.contextId) {
try {
const response = await fetch('/api/v1/tags/entity', {
@ -296,6 +297,7 @@ class TagPicker {
});
if (legacyResponse.ok) {
addResult = { action: null };
this.showSuccess(tag.name);
} else {
let legacyErrorDetail = '';
@ -311,6 +313,11 @@ class TagPicker {
throw new Error(errorDetail || 'Kunne ikke tilføje tag');
}
} else {
try {
addResult = await response.json();
} catch (parseError) {
addResult = { action: null };
}
// Show success feedback
this.showSuccess(tag.name);
}
@ -323,12 +330,31 @@ class TagPicker {
// Call callback if provided
if (this.onSelectCallback) {
this.onSelectCallback(tag);
this.onSelectCallback(tag, addResult);
}
if (addResult && addResult.action) {
this.runTagAction(tag, addResult.action);
}
this.hide();
}
runTagAction(tag, action) {
if (!action || !action.type) {
return;
}
const detail = {
tag,
action,
entity_type: this.contextType,
entity_id: this.contextId,
};
window.dispatchEvent(new CustomEvent('hub:tag-action', { detail }));
}
show(contextType = null, contextId = null, onSelect = null) {
// Use provided context OR fall back to page defaults
// Note: arguments are undefined if not passed, so check for null/undefined

View File

@ -0,0 +1,518 @@
(function () {
'use strict';
const DEFAULT_ACTION_CONFIG = {
default_category: null,
allow_company_templates: true,
allow_global_templates: true
};
let modalElement = null;
let modalInstance = null;
let currentCaseId = null;
let currentAction = null;
let availableTemplates = [];
let currentPreview = null;
function notify(message, level) {
if (typeof window.showNotification === 'function') {
window.showNotification(message, level || 'info');
return;
}
if (level === 'error') {
alert(message);
}
}
function ensureModal() {
if (modalElement) return;
const html = `
<div class="modal fade" id="taskTemplateSelectorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-file-earmark-check me-2"></i>Vaelg opgave-template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-lg-4">
<label class="form-label">Template-kilde</label>
<select id="ttSource" class="form-select">
<option value="all" selected>Firma + faelles</option>
<option value="company">Kun firma</option>
<option value="global">Kun faelles</option>
<option value="internal">Kun intern</option>
</select>
</div>
<div class="col-lg-4">
<label class="form-label">Kategori</label>
<select id="ttCategory" class="form-select">
<option value="">Alle</option>
<option value="onboarding">Onboarding</option>
<option value="offboarding">Offboarding</option>
<option value="simkort">Mobil / SIM-kort</option>
<option value="hardwarebestilling">Hardwarebestilling</option>
<option value="brugerandring">Brugeraendring</option>
<option value="andet">Andet</option>
</select>
</div>
<div class="col-lg-4">
<label class="form-label">Startdato</label>
<input id="ttStartDate" type="date" class="form-control" />
</div>
<div class="col-lg-8">
<label class="form-label">Template</label>
<input id="ttTemplateSearch" class="form-control mb-2" placeholder="Soeg template..." />
<select id="ttTemplate" class="form-select" size="8"></select>
<div id="ttTemplateEmpty" class="small text-muted mt-2 d-none">Ingen templates matchede dit filter.</div>
</div>
<div class="col-lg-4">
<label class="form-label">Oprettelsestype</label>
<select id="ttMode" class="form-select mb-3">
<option value="subcases">Opret som undersager</option>
<option value="tasks">Opret som tasks paa nuvaerende sag</option>
<option value="combined" selected>Kombineret</option>
</select>
<label class="form-label">Ansvarlig</label>
<select id="ttAssigneeMode" class="form-select mb-2">
<option value="template_default" selected>Brug standard fra template</option>
<option value="specific_user">Vaelg specifik medarbejder</option>
<option value="specific_role">Vaelg team/rolle</option>
</select>
<input id="ttAssigneeUserId" type="number" class="form-control mb-2 d-none" placeholder="Medarbejder ID" min="1" step="1" />
<input id="ttAssigneeRoleId" type="number" class="form-control d-none" placeholder="Rolle ID" min="1" step="1" />
</div>
</div>
<div class="border-top mt-4 pt-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Preview</h6>
<button type="button" class="btn btn-sm btn-outline-primary" id="ttPreviewBtn">Opdater preview</button>
</div>
<div id="ttPreviewSummary" class="small text-muted mb-2">Ingen preview endnu.</div>
<div id="ttPreviewList" class="list-group" style="max-height: 280px; overflow:auto;"></div>
</div>
<div class="border-top mt-4 pt-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Seneste template-koersler paa sagen</h6>
<button type="button" class="btn btn-sm btn-outline-secondary" id="ttRefreshRunsBtn">Opdater</button>
</div>
<div id="ttRunHistory" class="small text-muted">Ingen template-koersler endnu.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="ttRunBtn" disabled>Opret</button>
</div>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
modalElement = document.getElementById('taskTemplateSelectorModal');
modalInstance = new bootstrap.Modal(modalElement);
document.getElementById('ttSource').addEventListener('change', refreshTemplates);
document.getElementById('ttCategory').addEventListener('change', refreshTemplates);
document.getElementById('ttTemplateSearch').addEventListener('input', renderTemplateList);
document.getElementById('ttTemplate').addEventListener('change', onTemplateSelected);
document.getElementById('ttAssigneeMode').addEventListener('change', onAssigneeModeChanged);
document.getElementById('ttPreviewBtn').addEventListener('click', runPreview);
document.getElementById('ttRunBtn').addEventListener('click', runTemplate);
document.getElementById('ttRefreshRunsBtn').addEventListener('click', loadRunHistory);
modalElement.addEventListener('shown.bs.modal', () => {
document.getElementById('ttTemplateSearch').focus();
});
}
function todayIso() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${month}-${day}`;
}
async function fetchCase(caseId) {
const response = await fetch(`/api/v1/sag/${caseId}`, { credentials: 'include' });
if (!response.ok) {
throw new Error('Kunne ikke hente sag');
}
return response.json();
}
function getActionConfig() {
const cfg = (currentAction && currentAction.config) || {};
return { ...DEFAULT_ACTION_CONFIG, ...cfg };
}
async function refreshTemplates() {
const source = document.getElementById('ttSource').value;
const category = document.getElementById('ttCategory').value;
const params = new URLSearchParams();
if (source) params.set('source', source);
if (category) params.set('category', category);
try {
const caseRow = await fetchCase(currentCaseId);
if (caseRow && caseRow.customer_id) {
params.set('company_id', String(caseRow.customer_id));
}
const response = await fetch(`/api/v1/task-templates?${params.toString()}`, { credentials: 'include' });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke hente templates');
}
availableTemplates = await response.json();
renderTemplateList();
} catch (error) {
console.error('Failed to load templates:', error);
availableTemplates = [];
renderTemplateList();
notify(error.message || 'Kunne ikke hente templates', 'error');
}
}
function renderTemplateList() {
const search = String(document.getElementById('ttTemplateSearch').value || '').toLowerCase().trim();
const select = document.getElementById('ttTemplate');
const emptyState = document.getElementById('ttTemplateEmpty');
const filtered = availableTemplates.filter((template) => {
if (!search) return true;
const haystack = `${template.name || ''} ${template.description || ''} ${template.category || ''}`.toLowerCase();
return haystack.includes(search);
});
select.innerHTML = filtered.map((template) => {
const scopeLabel = template.template_type === 'company' ? 'Firma' : template.template_type === 'global' ? 'Faelles' : 'Intern';
const cat = template.category || 'andet';
return `<option value="${template.id}">${template.name} [${scopeLabel}] (${cat})</option>`;
}).join('');
emptyState.classList.toggle('d-none', filtered.length > 0);
document.getElementById('ttRunBtn').disabled = true;
currentPreview = null;
document.getElementById('ttPreviewSummary').textContent = 'Ingen preview endnu.';
document.getElementById('ttPreviewList').innerHTML = '';
}
function onTemplateSelected() {
currentPreview = null;
document.getElementById('ttRunBtn').disabled = true;
document.getElementById('ttPreviewSummary').textContent = 'Template valgt. Klik "Opdater preview".';
document.getElementById('ttPreviewList').innerHTML = '';
}
function onAssigneeModeChanged() {
const mode = document.getElementById('ttAssigneeMode').value;
const userInput = document.getElementById('ttAssigneeUserId');
const roleInput = document.getElementById('ttAssigneeRoleId');
userInput.classList.toggle('d-none', mode !== 'specific_user');
roleInput.classList.toggle('d-none', mode !== 'specific_role');
}
function buildPayload() {
const templateId = Number(document.getElementById('ttTemplate').value);
if (!templateId) {
throw new Error('Vaelg en template');
}
const mode = document.getElementById('ttMode').value;
const assigneeMode = document.getElementById('ttAssigneeMode').value;
const startDate = document.getElementById('ttStartDate').value || todayIso();
const assigneeUserIdRaw = document.getElementById('ttAssigneeUserId').value;
const assigneeRoleIdRaw = document.getElementById('ttAssigneeRoleId').value;
const payload = {
template_id: templateId,
start_date: startDate,
mode,
assignee_mode: assigneeMode,
};
if (assigneeMode === 'specific_user') {
const parsed = Number(assigneeUserIdRaw);
if (!parsed) throw new Error('Udfyld medarbejder ID');
payload.assignee_user_id = parsed;
}
if (assigneeMode === 'specific_role') {
const parsed = Number(assigneeRoleIdRaw);
if (!parsed) throw new Error('Udfyld rolle ID');
payload.assignee_role_id = parsed;
}
return payload;
}
function renderPreview(preview) {
const summary = preview.summary || {};
const list = Array.isArray(preview.items) ? preview.items : [];
document.getElementById('ttPreviewSummary').textContent =
`Du er ved at oprette: ${summary.subcases || 0} undersager, ${summary.tasks || 0} opgaver, ${summary.assignments || 0} tildelinger, ${summary.deadlines || 0} deadlines.`;
document.getElementById('ttPreviewList').innerHTML = list.map((item) => {
const typeLabel = item.item_type === 'subcase' ? 'Undersag' : 'Task';
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">${escapeHtml(item.title || '')}</div>
<div class="small text-muted">${escapeHtml(item.description || '')}</div>
</div>
<div class="text-end small text-muted">
<div>${typeLabel}</div>
<div>${escapeHtml(item.planned_due_date || '-')}</div>
</div>
</div>
</div>
`;
}).join('');
}
async function runPreview() {
if (!currentCaseId) return;
try {
const payload = buildPayload();
const response = await fetch(`/api/v1/cases/${currentCaseId}/template-preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail || 'Preview fejlede');
}
const preview = await response.json();
currentPreview = preview;
renderPreview(preview);
document.getElementById('ttRunBtn').disabled = false;
} catch (error) {
console.error('Preview failed:', error);
notify(error.message || 'Preview fejlede', 'error');
}
}
async function runTemplate() {
if (!currentCaseId) return;
if (!currentPreview) {
notify('Koer preview foerst', 'error');
return;
}
const runButton = document.getElementById('ttRunBtn');
runButton.disabled = true;
const originalText = runButton.textContent;
runButton.textContent = 'Opretter...';
try {
const payload = buildPayload();
const response = await fetch(`/api/v1/cases/${currentCaseId}/run-template`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail || 'Koersel fejlede');
}
const result = await response.json();
notify(`Template koert. Oprettet ${result.summary?.tasks || 0} tasks og ${result.summary?.subcases || 0} undersager.`, 'success');
if (typeof window.syncCaseTagsUi === 'function') {
window.syncCaseTagsUi();
}
if (typeof window.loadTodoSteps === 'function') {
window.loadTodoSteps();
}
await loadRunHistory();
document.getElementById('ttRunBtn').disabled = true;
document.getElementById('ttPreviewSummary').textContent = 'Template koert. Vaelg en ny template eller opdater preview igen.';
} catch (error) {
console.error('Run failed:', error);
notify(error.message || 'Koersel fejlede', 'error');
} finally {
runButton.disabled = false;
runButton.textContent = originalText;
}
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDateTime(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return String(value);
return parsed.toLocaleString('da-DK', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
async function loadRunHistory() {
const container = document.getElementById('ttRunHistory');
if (!container || !currentCaseId) return;
container.innerHTML = '<div class="text-muted">Indlaeser historik...</div>';
try {
const response = await fetch(`/api/v1/cases/${currentCaseId}/template-runs?limit=10`, {
credentials: 'include'
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail || 'Kunne ikke hente historik');
}
const rows = await response.json();
if (!Array.isArray(rows) || rows.length === 0) {
container.innerHTML = '<div class="text-muted">Ingen template-koersler endnu.</div>';
return;
}
container.innerHTML = rows.map((run) => {
const status = escapeHtml(run.status || 'ukendt');
const templateName = escapeHtml(run.template_name || `Template #${run.template_id || '-'}`);
const startedAt = formatDateTime(run.started_at);
const taskCount = Number(run.created_tasks || 0);
const subcaseCount = Number(run.created_subcases || 0);
const statusClass = status === 'completed' ? 'text-success' : (status === 'failed' ? 'text-danger' : 'text-muted');
return `
<div class="border rounded p-2 mb-2">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">${templateName}</div>
<div class="small text-muted">${escapeHtml(startedAt)}</div>
</div>
<span class="small fw-semibold ${statusClass}">${status}</span>
</div>
<div class="small mt-1">Oprettet: ${taskCount} opgaver, ${subcaseCount} undersager</div>
${run.template_id ? `
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="window.reuseCaseTemplateFromHistory(${Number(run.template_id)})">Vaelg igen</button>
</div>
` : ''}
${run.error_message ? `<div class="small text-danger mt-1">${escapeHtml(run.error_message)}</div>` : ''}
</div>
`;
}).join('');
} catch (error) {
container.innerHTML = `<div class="text-danger">${escapeHtml(error.message || 'Kunne ikke hente historik')}</div>`;
}
}
async function reuseTemplateFromHistory(templateId) {
const wantedId = Number(templateId || 0);
if (!wantedId) {
notify('Ugyldigt template-id', 'error');
return;
}
const sourceSelect = document.getElementById('ttSource');
const categorySelect = document.getElementById('ttCategory');
const searchInput = document.getElementById('ttTemplateSearch');
const templateSelect = document.getElementById('ttTemplate');
// Widen filters to make sure template is available in selector.
sourceSelect.value = 'all';
categorySelect.value = '';
searchInput.value = '';
await refreshTemplates();
const hasOption = Array.from(templateSelect.options).some((opt) => Number(opt.value) === wantedId);
if (!hasOption) {
notify('Template findes ikke i nuvaerende scope eller er deaktiveret', 'error');
return;
}
templateSelect.value = String(wantedId);
onTemplateSelected();
await runPreview();
}
async function openTaskTemplateSelectorModal(detailOrCaseId) {
ensureModal();
if (typeof detailOrCaseId === 'object' && detailOrCaseId !== null) {
currentCaseId = Number(detailOrCaseId.entity_id || detailOrCaseId.case_id || 0);
currentAction = detailOrCaseId.action || null;
} else {
currentCaseId = Number(detailOrCaseId || 0);
currentAction = null;
}
if (!currentCaseId) {
notify('Kunne ikke finde sag-id til template modal', 'error');
return;
}
document.getElementById('ttStartDate').value = todayIso();
document.getElementById('ttTemplateSearch').value = '';
document.getElementById('ttAssigneeMode').value = 'template_default';
document.getElementById('ttAssigneeUserId').value = '';
document.getElementById('ttAssigneeRoleId').value = '';
onAssigneeModeChanged();
const actionConfig = getActionConfig();
const sourceSelect = document.getElementById('ttSource');
sourceSelect.value = 'all';
if (!actionConfig.allow_company_templates && actionConfig.allow_global_templates) {
sourceSelect.value = 'global';
}
if (actionConfig.allow_company_templates && !actionConfig.allow_global_templates) {
sourceSelect.value = 'company';
}
const categorySelect = document.getElementById('ttCategory');
categorySelect.value = actionConfig.default_category || '';
await refreshTemplates();
await loadRunHistory();
modalInstance.show();
}
window.reuseCaseTemplateFromHistory = reuseTemplateFromHistory;
window.openTaskTemplateSelectorModal = openTaskTemplateSelectorModal;
window.addEventListener('hub:tag-action', function (event) {
const detail = event && event.detail ? event.detail : null;
if (!detail || !detail.action || detail.action.type !== 'open_task_template_modal') {
return;
}
// Auto-open is disabled. Users open the selector by clicking the template tag.
});
})();