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 = `
`;
}
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) => `
`).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)}
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();
}
}