async function loadWorkflowEditor() { const result = await apiGet('/api/workflow.php', { api: 'WorkflowData', _: Date.now() }); if (!result.success) { document.getElementById('workflowGraph').innerHTML = '
Could not load workflow data.
'; return; } workflowData = result.workflow; if (!selectedWorkflowTaskType && workflowData.task_types.length) { selectedWorkflowTaskType = String(workflowData.task_types[0].id); } if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) { selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null; } renderWorkflowEditor(); } function renderWorkflowEditor() { if (!workflowData) return; populateWorkflowSelects(); renderWorkflowLists(); renderWorkflowGraph(); } function populateWorkflowSelects() { const stateOptions = workflowData.states.map((state) => ({ id: state.id, name: state.name })); populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, { placeholder: 'From state' }); populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, { placeholder: 'To state' }); populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, { placeholder: 'State' }); populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, { placeholder: 'Task type', selectedValue: selectedWorkflowTaskType ?? '' }); const assignedStates = getSelectedWorkflowAssignments() .map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state))) .filter(Boolean); const selectedType = getSelectedWorkflowTaskType(); populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, { placeholder: 'No default state', selectedValue: selectedType?.default_state ?? '' }); } function renderWorkflowLists() { const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state])); const selectedAssignments = getSelectedWorkflowAssignments(); const stateList = document.getElementById('workflowStateList'); stateList.innerHTML = workflowData.states.map((state) => `
${escapeHtml(state.name)}
`).join('') || '
No states yet.
'; const transitionList = document.getElementById('workflowTransitionList'); transitionList.innerHTML = workflowData.transitions.map((transition) => `
${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)} ${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)} ${escapeHtml(transition.action_name)}
`).join('') || '
No transitions yet.
'; const assignmentList = document.getElementById('workflowAssignmentList'); assignmentList.innerHTML = selectedAssignments.map((assignment) => `
${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}
`).join('') || '
No assigned states yet.
'; } function getSelectedWorkflowTaskType() { return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null; } function getSelectedWorkflowAssignments() { if (!selectedWorkflowTaskType) return []; return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType)); } function renderWorkflowGraph() { const graph = document.getElementById('workflowGraph'); const empty = document.getElementById('workflowGraphEmpty'); if (!graph || !empty) return; const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state))); const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id))); const visibleTransitions = workflowData.transitions.filter((transition) => ( assignedStateIds.has(Number(transition.from_id)) && assignedStateIds.has(Number(transition.to_id)) )); if (!visibleStates.length) { if (workflowGraph) { workflowGraph.destroy(); workflowGraph = null; } graph.innerHTML = ''; empty.hidden = false; empty.textContent = selectedWorkflowTaskType ? 'Assign states to this task type to render its workflow graph.' : 'Select a task type to render its workflow graph.'; return; } empty.hidden = true; if (typeof cytoscape !== 'function') { graph.innerHTML = '
Cytoscape.js could not be loaded.
'; return; } const elements = [ ...visibleStates.map((state) => ({ data: { id: `state-${state.id}`, label: state.name, color: state.color } })), ...visibleTransitions.map((transition) => ({ data: { id: `transition-${transition.id}`, source: `state-${transition.from_id}`, target: `state-${transition.to_id}`, label: transition.action_name } })) ]; const styles = getComputedStyle(document.documentElement); const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46'; const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6'; const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4'; if (workflowGraph) { workflowGraph.destroy(); } workflowGraph = cytoscape({ container: graph, elements, style: [ { selector: 'node', style: { 'background-color': 'data(color)', 'border-color': borderColor, 'border-width': 1, 'color': bodyColor, 'font-size': 13, 'font-weight': 700, 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'wrap', 'text-max-width': 90, 'width': 86, 'height': 42, 'shape': 'round-rectangle' } }, { selector: 'edge', style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'line-color': secondaryColor, 'target-arrow-color': secondaryColor, 'color': secondaryColor, 'font-size': 11, 'label': 'data(label)', 'text-rotation': 'autorotate', 'text-margin-y': -8, 'width': 2 } } ], layout: { name: 'breadthfirst', directed: true, padding: 36, spacingFactor: 1.25 } }); } async function saveWorkflowAction(api, body = {}, params = {}) { const result = await apiPost('/api/workflow.php', { api, ...params }, body); if (!result.success) { alert(result.error || 'Could not save workflow change.'); return false; } await loadWorkflowEditor(); return true; } async function loadAdminOptions() { let result = null; try { result = await apiGet('/api/admin_options.php', { api: 'OptionsData', _: Date.now() }); } catch (error) { result = { success: false, error: 'Could not load admin options.' }; } if (!result.success) { const typeEditor = document.getElementById('adminSelectedTypeEditor'); const priorityList = document.getElementById('adminPriorityList'); const userRightsList = document.getElementById('adminUserRightsList'); if (typeEditor) { typeEditor.innerHTML = '
Could not load task types.
'; } if (priorityList) { priorityList.innerHTML = '
Could not load priorities.
'; } if (userRightsList) { userRightsList.innerHTML = '
Could not load users.
'; } return; } adminOptionsData = { types: result.options?.types ?? [], priorities: result.options?.priorities ?? [], custom_fields: result.options?.custom_fields ?? [], users: result.options?.users ?? [], rights: result.options?.rights ?? [], user_rights: result.options?.user_rights ?? [], projects: result.options?.projects ?? [], user_access: result.options?.user_access ?? [] }; if (!selectedAdminTaskType && adminOptionsData.types.length) { selectedAdminTaskType = String(adminOptionsData.types[0].id); } if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) { selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null; } if (!selectedAdminUser && adminOptionsData.users.length) { selectedAdminUser = String(adminOptionsData.users[0].id); } if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) { selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null; } renderAdminOptions(); } function renderAdminOptions() { if (!adminOptionsData) return; const types = adminOptionsData.types ?? []; const priorities = adminOptionsData.priorities ?? []; populateSelect(document.getElementById('adminTaskTypeSelect'), types, { placeholder: 'Select task type', selectedValue: selectedAdminTaskType ?? '' }); renderSelectedAdminTaskType(); renderAdminCustomFields(); renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList')); renderAdminUsers(); } function getSelectedAdminTaskType() { return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null; } function renderSelectedAdminTaskType() { const container = document.getElementById('adminSelectedTypeEditor'); if (!container) return; const type = getSelectedAdminTaskType(); if (!type) { container.innerHTML = '
Create or select a task type first.
'; return; } container.innerHTML = `
${type.icon ? `` : ''}
`; } function renderAdminCustomFields() { const container = document.getElementById('adminCustomFieldList'); if (!container) return; if (!selectedAdminTaskType) { container.innerHTML = '
Select a task type first.
'; return; } const fields = (adminOptionsData.custom_fields ?? []) .filter((field) => String(field.task_type) === String(selectedAdminTaskType)); container.innerHTML = fields.map((field) => `
`).join('') || '
No custom fields yet.
'; } function renderAdminOptionList(kind, options, container) { if (!container) return; container.innerHTML = options.map((option) => `
${option.icon ? `` : ''}
`).join('') || '
No entries yet.
'; } function getSelectedAdminUser() { return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null; } function userHasAdminRight(userId, rightId) { return (adminOptionsData?.user_rights ?? []).some((entry) => ( String(entry.user_id) === String(userId) && String(entry.right_id) === String(rightId) )); } function getAdminUserAccess(userId) { return (adminOptionsData?.user_access ?? []).filter((entry) => ( String(entry.user_id) === String(userId) )); } function getAdminProject(projectId) { return (adminOptionsData?.projects ?? []).find((project) => ( String(project.id) === String(projectId) )) ?? null; } function renderAdminUsers() { const select = document.getElementById('adminUserSelect'); const container = document.getElementById('adminUserRightsList'); if (!select || !container) return; const users = adminOptionsData.users ?? []; const rights = adminOptionsData.rights ?? []; const projects = adminOptionsData.projects ?? []; const selectedUser = getSelectedAdminUser(); populateSelect(select, users, { placeholder: 'Select user', selectedValue: selectedAdminUser ?? '' }); if (!selectedUser) { container.innerHTML = '
Select a user first.
'; return; } if (!rights.length) { container.innerHTML = '
No rights configured yet.
'; return; } const explicitAccess = getAdminUserAccess(selectedUser.id); const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id))); const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id)); const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id))); const addableProjects = projects.filter((project) => ( !explicitProjectIds.has(String(project.id)) && !ownerProjectIds.has(String(project.id)) )); container.innerHTML = `
${renderUser(selectedUser)}

Rights

${rights.map((right) => { const checked = userHasAdminRight(selectedUser.id, right.id); const isSelfAdminRight = appFlag('isAdmin') && String(selectedUser.id) === String(getCurrentUserId()) && String(right.name) === 'Admin'; return ` `; }).join('')}

Project Access

${explicitAccess.map((access) => { const project = getAdminProject(access.project_id); return `
${escapeHtml(project?.name ?? access.project_id)} ${escapeHtml(access.project_id)}
`; }).join('') || '
No explicit project access yet.
'}
${ownerProjects.length ? `
Owner access ${ownerProjects.map((project) => ` ${escapeHtml(project.name)} ${escapeHtml(project.id)} `).join('')}
` : ''}
`; } async function setAdminUserRight(userId, rightId, enabled) { const result = await apiPost('/api/admin_options.php', { api: 'SetUserRight' }, { user_id: userId, right_id: rightId, enabled: enabled ? 1 : 0 }); if (!result.success) { alert(result.error || 'Could not update user right.'); return false; } await loadAdminOptions(); return true; } async function grantAdminProjectAccess(userId, projectId) { const result = await apiPost('/api/admin_options.php', { api: 'GrantProjectAccess' }, { user_id: userId, project_id: projectId }); if (!result.success) { alert(result.error || 'Could not add project access.'); return false; } await loadAdminOptions(); return true; } async function revokeAdminProjectAccess(accessId) { const result = await apiPost('/api/admin_options.php', { api: 'RevokeProjectAccess', access_id: accessId }); if (!result.success) { alert(result.error || 'Could not remove project access.'); return false; } await loadAdminOptions(); return true; } async function saveAdminOptionForm(kind, form, optionId = null) { const formData = new FormData(form); formData.set('kind', kind); if (optionId) { formData.set('id', optionId); } const result = await apiPostForm('/api/admin_options.php', { api: optionId ? 'UpdateOption' : 'CreateOption' }, formData); if (!result.success) { alert(result.error || 'Could not save option.'); return false; } if (kind === 'type' && !optionId && result.option_id) { selectedAdminTaskType = String(result.option_id); } taskLookupCache.types = null; taskLookupCache.priorities = null; await populateTaskOptionSelects(); await loadAdminOptions(); return true; } async function deleteAdminOption(kind, optionId) { const result = await apiPost('/api/admin_options.php', { api: 'DeleteOption', kind, id: optionId }); if (!result.success) { alert(result.error || 'Could not delete option.'); return false; } if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) { selectedAdminTaskType = null; } taskLookupCache.types = null; taskLookupCache.priorities = null; await populateTaskOptionSelects(); await loadAdminOptions(); return true; } async function saveAdminCustomFieldForm(form, fieldId = null) { if (!selectedAdminTaskType) { alert('Select a task type first.'); return false; } const data = Object.fromEntries(new FormData(form)); data.task_type = selectedAdminTaskType; if (fieldId) { data.id = fieldId; } const result = await apiPost('/api/admin_options.php', { api: fieldId ? 'UpdateCustomField' : 'CreateCustomField' }, data); if (!result.success) { alert(result.error || 'Could not save custom field.'); return false; } await loadAdminOptions(); return true; } async function deleteAdminCustomField(fieldId) { const result = await apiPost('/api/admin_options.php', { api: 'DeleteCustomField', field_id: fieldId }); if (!result.success) { alert(result.error || 'Could not delete custom field.'); return false; } await loadAdminOptions(); return true; } function loadAdmin(section = 'workflows', pushHistory = true) { if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') { loadDashboard(false); return; } const view = showView('adminView'); if (!view) return; if (!['workflows', 'options', 'users'].includes(section)) { section = 'workflows'; } document.querySelectorAll('[data-admin-section]').forEach((button) => { button.classList.toggle('is-active', button.dataset.adminSection === section); }); document.querySelectorAll('.admin-panel').forEach((panel) => { panel.hidden = true; }); const panelBySection = { workflows: 'adminWorkflowsPanel', options: 'adminOptionsPanel', users: 'adminUsersPanel' }; const panel = document.getElementById(panelBySection[section]); if (panel) panel.hidden = false; if (pushHistory) { const url = new URL(window.location.href); url.searchParams.set('page', 'home'); url.searchParams.set('admin', section); url.searchParams.delete('task'); url.searchParams.delete('project'); url.searchParams.delete('tasks'); url.searchParams.delete('version'); url.searchParams.delete('profile'); window.history.pushState({}, '', url); } if (section === 'workflows') { loadWorkflowEditor(); } else if (section === 'options' || section === 'users') { loadAdminOptions(); } }