bmc_hub/static/js/task-template-selector.js
Christian f2c8af4680 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.
2026-05-01 20:58:13 +02:00

519 lines
22 KiB
JavaScript

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