diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 65593e5..02d0764 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -1199,7 +1199,8 @@ window.addEventListener('unhandledrejection', function(event) {
});
-
+
+
diff --git a/app/tags/backend/models.py b/app/tags/backend/models.py
index 435ded5..56ecedc 100644
--- a/app/tags/backend/models.py
+++ b/app/tags/backend/models.py
@@ -78,6 +78,7 @@ class EntityTag(EntityTagBase):
id: int
tagged_by: Optional[int]
tagged_at: datetime
+ action: Optional[dict] = None
class Config:
from_attributes = True
diff --git a/app/tags/backend/router.py b/app/tags/backend/router.py
index d0e5567..9421a8d 100644
--- a/app/tags/backend/router.py
+++ b/app/tags/backend/router.py
@@ -207,6 +207,28 @@ def _tag_row_to_response(row: dict) -> dict:
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
return out
+
+def _resolve_tag_action_for_add(tag_id: int) -> Optional[dict]:
+ workflow = execute_query_single(
+ """
+ SELECT action_type, COALESCE(action_config, '{}'::jsonb) AS action_config
+ FROM tag_workflows
+ WHERE tag_id = %s
+ AND trigger_event = 'on_add'
+ AND is_active = TRUE
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (tag_id,),
+ )
+ if not workflow:
+ return None
+
+ return {
+ "type": workflow.get("action_type"),
+ "config": workflow.get("action_config") or {},
+ }
+
# ============= TAG GROUPS =============
@router.get("/groups", response_model=List[TagGroup])
@@ -514,6 +536,12 @@ async def add_tag_to_entity(entity_tag: EntityTagCreate):
)
if not result:
raise HTTPException(status_code=409, detail="Tag already exists on entity")
+
+ action = _resolve_tag_action_for_add(entity_tag.tag_id)
+ if action:
+ result = dict(result)
+ result["action"] = action
+
return result
@router.delete("/entity")
diff --git a/main.py b/main.py
index a8f9f0b..876a99a 100644
--- a/main.py
+++ b/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 public_router as bottom_bar_public_api
from app.modules.rentals.backend import router as rentals_api
+from app.modules.task_templates.backend import router as task_templates_api
# Configure logging
logging.basicConfig(
@@ -454,6 +455,7 @@ app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
+app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api
diff --git a/migrations/183_task_templates_mvp.sql b/migrations/183_task_templates_mvp.sql
new file mode 100644
index 0000000..c952d39
--- /dev/null
+++ b/migrations/183_task_templates_mvp.sql
@@ -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
+ );
diff --git a/static/js/tag-picker.js b/static/js/tag-picker.js
index f7f3386..8695adc 100644
--- a/static/js/tag-picker.js
+++ b/static/js/tag-picker.js
@@ -260,6 +260,7 @@ class TagPicker {
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
// If context provided, add tag to entity
+ let addResult = null;
if (this.contextType && this.contextId) {
try {
const response = await fetch('/api/v1/tags/entity', {
@@ -296,6 +297,7 @@ class TagPicker {
});
if (legacyResponse.ok) {
+ addResult = { action: null };
this.showSuccess(tag.name);
} else {
let legacyErrorDetail = '';
@@ -311,6 +313,11 @@ class TagPicker {
throw new Error(errorDetail || 'Kunne ikke tilføje tag');
}
} else {
+ try {
+ addResult = await response.json();
+ } catch (parseError) {
+ addResult = { action: null };
+ }
// Show success feedback
this.showSuccess(tag.name);
}
@@ -323,12 +330,31 @@ class TagPicker {
// Call callback if provided
if (this.onSelectCallback) {
- this.onSelectCallback(tag);
+ this.onSelectCallback(tag, addResult);
+ }
+
+ if (addResult && addResult.action) {
+ this.runTagAction(tag, addResult.action);
}
this.hide();
}
+ runTagAction(tag, action) {
+ if (!action || !action.type) {
+ return;
+ }
+
+ const detail = {
+ tag,
+ action,
+ entity_type: this.contextType,
+ entity_id: this.contextId,
+ };
+
+ window.dispatchEvent(new CustomEvent('hub:tag-action', { detail }));
+ }
+
show(contextType = null, contextId = null, onSelect = null) {
// Use provided context OR fall back to page defaults
// Note: arguments are undefined if not passed, so check for null/undefined
diff --git a/static/js/task-template-selector.js b/static/js/task-template-selector.js
new file mode 100644
index 0000000..2e87bb4
--- /dev/null
+++ b/static/js/task-template-selector.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ingen templates matchede dit filter.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview
+
+
+
Ingen preview endnu.
+
+
+
+
+
+
Seneste template-koersler paa sagen
+
+
+
Ingen template-koersler endnu.
+
+
+
+
+
+
`;
+
+ 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 `
`;
+ }).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 `
+
+
+
+
${escapeHtml(item.title || '')}
+
${escapeHtml(item.description || '')}
+
+
+
${typeLabel}
+
${escapeHtml(item.planned_due_date || '-')}
+
+
+
+ `;
+ }).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, ''');
+ }
+
+ 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 = '
Indlaeser historik...
';
+
+ 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 = '
Ingen template-koersler endnu.
';
+ 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 `
+
+
+
+
${templateName}
+
${escapeHtml(startedAt)}
+
+
${status}
+
+
Oprettet: ${taskCount} opgaver, ${subcaseCount} undersager
+ ${run.template_id ? `
+
+
+
+ ` : ''}
+ ${run.error_message ? `
${escapeHtml(run.error_message)}
` : ''}
+
+ `;
+ }).join('');
+ } catch (error) {
+ container.innerHTML = `
${escapeHtml(error.message || 'Kunne ikke hente historik')}
`;
+ }
+ }
+
+ 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.
+ });
+})();