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:
parent
785a2d3ffe
commit
f2c8af4680
@ -4190,7 +4190,21 @@
|
|||||||
|
|
||||||
// Set default context for keyboard shortcuts (Option+Shift+T)
|
// Set default context for keyboard shortcuts (Option+Shift+T)
|
||||||
if (window.setTagPickerContext) {
|
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
|
// Load Hardware & Locations
|
||||||
@ -5381,7 +5395,7 @@
|
|||||||
return normalizeLegacyTags(legacyTags);
|
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) {
|
if (response.ok) {
|
||||||
const genericTags = await response.json();
|
const genericTags = await response.json();
|
||||||
tags = Array.isArray(genericTags) ? genericTags : [];
|
tags = Array.isArray(genericTags) ? genericTags : [];
|
||||||
@ -5434,7 +5448,7 @@
|
|||||||
if (!suggestionsContainer) return;
|
if (!suggestionsContainer) return;
|
||||||
|
|
||||||
try {
|
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');
|
if (!response.ok) throw new Error('Kunne ikke hente forslag');
|
||||||
|
|
||||||
const suggestions = await response.json();
|
const suggestions = await response.json();
|
||||||
@ -5470,6 +5484,7 @@
|
|||||||
const response = await fetch('/api/v1/tags/entity', {
|
const response = await fetch('/api/v1/tags/entity', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
|
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');
|
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();
|
await syncCaseTagsUi();
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Tag tilfoejet', 'success');
|
showNotification('Tag tilfoejet', 'success');
|
||||||
@ -5520,6 +5546,8 @@
|
|||||||
await loadCaseTagSuggestions();
|
await loadCaseTagSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.syncCaseTagsUi = syncCaseTagsUi;
|
||||||
|
|
||||||
let todoUserId = null;
|
let todoUserId = null;
|
||||||
|
|
||||||
function getTodoUserId() {
|
function getTodoUserId() {
|
||||||
@ -6152,14 +6180,22 @@
|
|||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<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>
|
<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"
|
<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">
|
title="Tilføj tag">
|
||||||
<i class="bi bi-plus-lg"></i>
|
<i class="bi bi-plus-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="max-height: 240px; overflow: auto;">
|
<div class="card-body" style="max-height: 240px; overflow: auto;">
|
||||||
<div id="case-tags-module" class="mb-2">
|
<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>
|
||||||
<div class="border-top pt-2">
|
<div class="border-top pt-2">
|
||||||
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
|
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
|
||||||
@ -15509,6 +15545,127 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────── -->
|
<!-- ── Beskrivelse inline editor ───────────────────────────────────────── -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@ -1493,6 +1493,11 @@
|
|||||||
display: none;
|
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 .card-header,
|
||||||
.card[data-module].module-empty-compact .module-header {
|
.card[data-module].module-empty-compact .module-header {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@ -4679,7 +4684,9 @@
|
|||||||
|
|
||||||
// Set default context for keyboard shortcuts (Option+Shift+T)
|
// Set default context for keyboard shortcuts (Option+Shift+T)
|
||||||
if (window.setTagPickerContext) {
|
if (window.setTagPickerContext) {
|
||||||
window.setTagPickerContext('case', {{ case.id }}, () => syncCaseTagsUi());
|
window.setTagPickerContext('case', {{ case.id }}, () => {
|
||||||
|
syncCaseTagsUi();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Hardware & Locations
|
// Load Hardware & Locations
|
||||||
@ -5885,7 +5892,7 @@
|
|||||||
return normalizeLegacyTags(legacyTags);
|
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) {
|
if (response.ok) {
|
||||||
const genericTags = await response.json();
|
const genericTags = await response.json();
|
||||||
tags = Array.isArray(genericTags) ? genericTags : [];
|
tags = Array.isArray(genericTags) ? genericTags : [];
|
||||||
@ -5910,14 +5917,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
moduleContainer.innerHTML = tags.map((tag) => `
|
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.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"
|
${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>` : ''}
|
style="font-size: 0.6rem; vertical-align: middle;"></button>` : ''}
|
||||||
</span>
|
</span>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
wireCaseTagTemplateClicks(moduleContainer);
|
||||||
|
|
||||||
if (usingLegacyCaseTags) {
|
if (usingLegacyCaseTags) {
|
||||||
moduleContainer.insertAdjacentHTML(
|
moduleContainer.insertAdjacentHTML(
|
||||||
'beforeend',
|
'beforeend',
|
||||||
@ -5938,7 +5947,7 @@
|
|||||||
if (!suggestionsContainer) return;
|
if (!suggestionsContainer) return;
|
||||||
|
|
||||||
try {
|
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');
|
if (!response.ok) throw new Error('Kunne ikke hente forslag');
|
||||||
|
|
||||||
const suggestions = await response.json();
|
const suggestions = await response.json();
|
||||||
@ -5974,6 +5983,7 @@
|
|||||||
const response = await fetch('/api/v1/tags/entity', {
|
const response = await fetch('/api/v1/tags/entity', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
|
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');
|
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();
|
await syncCaseTagsUi();
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Tag tilfoejet', 'success');
|
showNotification('Tag tilfoejet', 'success');
|
||||||
@ -5992,27 +6013,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeCaseTagAndSync(tagId) {
|
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 {
|
try {
|
||||||
if (window.removeEntityTag) {
|
const genericResponse = await fetch(`/api/v1/tags/entity/case/${caseId}/${numericTagId}`, {
|
||||||
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}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
if (!legacyResponse.ok) {
|
if (genericResponse.ok) {
|
||||||
throw error;
|
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();
|
await syncCaseTagsUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6024,6 +6073,45 @@
|
|||||||
await loadCaseTagSuggestions();
|
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;
|
let todoUserId = null;
|
||||||
|
|
||||||
function getTodoUserId() {
|
function getTodoUserId() {
|
||||||
@ -6652,7 +6740,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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"
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
@ -6661,9 +6749,23 @@
|
|||||||
<i class="bi bi-plus-lg"></i>
|
<i class="bi bi-plus-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="max-height: 240px; overflow: auto;">
|
<div class="card-body">
|
||||||
<div id="case-tags-module" class="mb-2">
|
<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>
|
||||||
<div class="border-top pt-2">
|
<div class="border-top pt-2">
|
||||||
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
|
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
|
||||||
@ -13455,7 +13557,7 @@
|
|||||||
const hasContent = moduleHasContent(el);
|
const hasContent = moduleHasContent(el);
|
||||||
const isTimeModule = moduleName === 'time';
|
const isTimeModule = moduleName === 'time';
|
||||||
const isShippingModule = moduleName === 'shipping';
|
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 pref = modulePrefs[moduleName];
|
||||||
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
||||||
|
|
||||||
@ -16871,6 +16973,292 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────── -->
|
<!-- ── Beskrivelse inline editor ───────────────────────────────────────── -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
1
app/modules/task_templates/backend/__init__.py
Normal file
1
app/modules/task_templates/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Task templates backend package."""
|
||||||
97
app/modules/task_templates/backend/models.py
Normal file
97
app/modules/task_templates/backend/models.py
Normal 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
|
||||||
664
app/modules/task_templates/backend/router.py
Normal file
664
app/modules/task_templates/backend/router.py
Normal 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
|
||||||
@ -107,6 +107,9 @@
|
|||||||
<a class="nav-link" href="#email-templates" data-tab="email-templates">
|
<a class="nav-link" href="#email-templates" data-tab="email-templates">
|
||||||
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
|
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
|
||||||
</a>
|
</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">
|
<a class="nav-link" href="/admin/bmc-office-upload">
|
||||||
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
|
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
|
||||||
</a>
|
</a>
|
||||||
@ -524,6 +527,104 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Users & Groups -->
|
||||||
<div class="tab-pane fade" id="users">
|
<div class="tab-pane fade" id="users">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
@ -3856,6 +3957,9 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
} else if (tab === 'tags') {
|
} else if (tab === 'tags') {
|
||||||
loadTagsManagement();
|
loadTagsManagement();
|
||||||
|
} else if (tab === 'task-templates') {
|
||||||
|
loadTaskTemplateCustomers();
|
||||||
|
loadTaskTemplates();
|
||||||
} else if (tab === 'telefoni') {
|
} else if (tab === 'telefoni') {
|
||||||
renderTelefoniSettings();
|
renderTelefoniSettings();
|
||||||
} else if (tab === 'mission') {
|
} else if (tab === 'mission') {
|
||||||
@ -4882,6 +4986,714 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Email Template Modal -->
|
||||||
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
|
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
|
|||||||
@ -1199,7 +1199,8 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></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/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
|
|||||||
@ -78,6 +78,7 @@ class EntityTag(EntityTagBase):
|
|||||||
id: int
|
id: int
|
||||||
tagged_by: Optional[int]
|
tagged_by: Optional[int]
|
||||||
tagged_at: datetime
|
tagged_at: datetime
|
||||||
|
action: Optional[dict] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@ -207,6 +207,28 @@ def _tag_row_to_response(row: dict) -> dict:
|
|||||||
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
|
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
|
||||||
return out
|
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 =============
|
# ============= TAG GROUPS =============
|
||||||
|
|
||||||
@router.get("/groups", response_model=List[TagGroup])
|
@router.get("/groups", response_model=List[TagGroup])
|
||||||
@ -514,6 +536,12 @@ async def add_tag_to_entity(entity_tag: EntityTagCreate):
|
|||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=409, detail="Tag already exists on entity")
|
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
|
return result
|
||||||
|
|
||||||
@router.delete("/entity")
|
@router.delete("/entity")
|
||||||
|
|||||||
2
main.py
2
main.py
@ -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 router as bottom_bar_api
|
||||||
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_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.rentals.backend import router as rentals_api
|
||||||
|
from app.modules.task_templates.backend import router as task_templates_api
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
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_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(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(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:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
from app.modules.links.backend import router as links_api
|
||||||
|
|||||||
125
migrations/183_task_templates_mvp.sql
Normal file
125
migrations/183_task_templates_mvp.sql
Normal 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
|
||||||
|
);
|
||||||
@ -260,6 +260,7 @@ class TagPicker {
|
|||||||
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
|
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
|
||||||
|
|
||||||
// If context provided, add tag to entity
|
// If context provided, add tag to entity
|
||||||
|
let addResult = null;
|
||||||
if (this.contextType && this.contextId) {
|
if (this.contextType && this.contextId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/tags/entity', {
|
const response = await fetch('/api/v1/tags/entity', {
|
||||||
@ -296,6 +297,7 @@ class TagPicker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (legacyResponse.ok) {
|
if (legacyResponse.ok) {
|
||||||
|
addResult = { action: null };
|
||||||
this.showSuccess(tag.name);
|
this.showSuccess(tag.name);
|
||||||
} else {
|
} else {
|
||||||
let legacyErrorDetail = '';
|
let legacyErrorDetail = '';
|
||||||
@ -311,6 +313,11 @@ class TagPicker {
|
|||||||
throw new Error(errorDetail || 'Kunne ikke tilføje tag');
|
throw new Error(errorDetail || 'Kunne ikke tilføje tag');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
|
addResult = await response.json();
|
||||||
|
} catch (parseError) {
|
||||||
|
addResult = { action: null };
|
||||||
|
}
|
||||||
// Show success feedback
|
// Show success feedback
|
||||||
this.showSuccess(tag.name);
|
this.showSuccess(tag.name);
|
||||||
}
|
}
|
||||||
@ -323,12 +330,31 @@ class TagPicker {
|
|||||||
|
|
||||||
// Call callback if provided
|
// Call callback if provided
|
||||||
if (this.onSelectCallback) {
|
if (this.onSelectCallback) {
|
||||||
this.onSelectCallback(tag);
|
this.onSelectCallback(tag, addResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addResult && addResult.action) {
|
||||||
|
this.runTagAction(tag, addResult.action);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hide();
|
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) {
|
show(contextType = null, contextId = null, onSelect = null) {
|
||||||
// Use provided context OR fall back to page defaults
|
// Use provided context OR fall back to page defaults
|
||||||
// Note: arguments are undefined if not passed, so check for null/undefined
|
// Note: arguments are undefined if not passed, so check for null/undefined
|
||||||
|
|||||||
518
static/js/task-template-selector.js
Normal file
518
static/js/task-template-selector.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue
Block a user