746 lines
27 KiB
JavaScript
746 lines
27 KiB
JavaScript
async function loadWorkflowEditor() {
|
|
const result = await apiGet('/api/workflow.php', {
|
|
api: 'WorkflowData',
|
|
_: Date.now()
|
|
});
|
|
|
|
if (!result.success) {
|
|
document.getElementById('workflowGraph').innerHTML = '<div class="alert alert-danger m-3">Could not load workflow data.</div>';
|
|
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) => `
|
|
<div class="workflow-list-item">
|
|
<div class="workflow-list-main">
|
|
<span class="workflow-color-dot" style="background: ${escapeHtml(state.color)}"></span>
|
|
<span>${escapeHtml(state.name)}</span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-state="${escapeHtml(state.id)}">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`).join('') || '<div class="text-secondary">No states yet.</div>';
|
|
|
|
const transitionList = document.getElementById('workflowTransitionList');
|
|
transitionList.innerHTML = workflowData.transitions.map((transition) => `
|
|
<div class="workflow-list-item">
|
|
<div class="workflow-list-main">
|
|
<span>${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}</span>
|
|
<i class="fa-solid fa-arrow-right"></i>
|
|
<span>${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}</span>
|
|
<span class="text-secondary">${escapeHtml(transition.action_name)}</span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-transition="${escapeHtml(transition.id)}">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`).join('') || '<div class="text-secondary">No transitions yet.</div>';
|
|
|
|
const assignmentList = document.getElementById('workflowAssignmentList');
|
|
assignmentList.innerHTML = selectedAssignments.map((assignment) => `
|
|
<div class="workflow-list-item">
|
|
<div class="workflow-list-main">
|
|
<span>${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}</span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-remove-assignment="${escapeHtml(assignment.id)}">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`).join('') || '<div class="text-secondary">No assigned states yet.</div>';
|
|
}
|
|
|
|
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 = '<div class="alert alert-warning m-3">Cytoscape.js could not be loaded.</div>';
|
|
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 = '<div class="text-danger">Could not load task types.</div>';
|
|
}
|
|
|
|
if (priorityList) {
|
|
priorityList.innerHTML = '<div class="text-danger">Could not load priorities.</div>';
|
|
}
|
|
|
|
if (userRightsList) {
|
|
userRightsList.innerHTML = '<div class="text-danger">Could not load users.</div>';
|
|
}
|
|
|
|
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 = '<div class="text-secondary">Create or select a task type first.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<form class="admin-option-item admin-selected-option" data-admin-option-edit="type" data-option-id="${escapeHtml(type.id)}">
|
|
<div class="admin-option-icon">
|
|
${type.icon
|
|
? `<img src="${escapeHtml(type.icon)}" alt="">`
|
|
: '<i class="fa-solid fa-tag"></i>'}
|
|
</div>
|
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(type.name)}" required maxlength="20">
|
|
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
|
<label class="admin-option-remove-logo">
|
|
<input type="checkbox" name="remove_logo" value="1">
|
|
<span>Remove logo</span>
|
|
</label>
|
|
<div class="admin-option-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="type" data-option-id="${escapeHtml(type.id)}" title="Delete">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
function renderAdminCustomFields() {
|
|
const container = document.getElementById('adminCustomFieldList');
|
|
|
|
if (!container) return;
|
|
|
|
if (!selectedAdminTaskType) {
|
|
container.innerHTML = '<div class="text-secondary">Select a task type first.</div>';
|
|
return;
|
|
}
|
|
|
|
const fields = (adminOptionsData.custom_fields ?? [])
|
|
.filter((field) => String(field.task_type) === String(selectedAdminTaskType));
|
|
|
|
container.innerHTML = fields.map((field) => `
|
|
<form class="admin-custom-field-item" data-admin-custom-field-edit="${escapeHtml(field.id)}">
|
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(field.name)}" required maxlength="128">
|
|
<select class="form-select form-select-sm" name="type" required>
|
|
${customFieldTypes.map((type) => `
|
|
<option value="${escapeHtml(type.id)}" ${String(type.id) === String(field.type) ? 'selected' : ''}>${escapeHtml(type.name)}</option>
|
|
`).join('')}
|
|
</select>
|
|
<input class="form-control form-control-sm" name="value" value="${escapeHtml(field.raw_value ?? '')}">
|
|
<div class="admin-option-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-custom-field-delete="${escapeHtml(field.id)}" title="Delete">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`).join('') || '<div class="text-secondary">No custom fields yet.</div>';
|
|
}
|
|
|
|
function renderAdminOptionList(kind, options, container) {
|
|
if (!container) return;
|
|
|
|
container.innerHTML = options.map((option) => `
|
|
<form class="admin-option-item" data-admin-option-edit="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}">
|
|
<div class="admin-option-icon">
|
|
${option.icon
|
|
? `<img src="${escapeHtml(option.icon)}" alt="">`
|
|
: '<i class="fa-solid fa-tag"></i>'}
|
|
</div>
|
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(option.name)}" required maxlength="128">
|
|
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
|
<label class="admin-option-remove-logo">
|
|
<input type="checkbox" name="remove_logo" value="1">
|
|
<span>Remove logo</span>
|
|
</label>
|
|
<div class="admin-option-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}" title="Delete">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`).join('') || '<div class="text-secondary">No entries yet.</div>';
|
|
}
|
|
|
|
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 = '<div class="text-secondary">Select a user first.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!rights.length) {
|
|
container.innerHTML = '<div class="text-secondary">No rights configured yet.</div>';
|
|
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 = `
|
|
<div class="admin-user-summary">
|
|
${renderUser(selectedUser)}
|
|
</div>
|
|
|
|
<div class="admin-user-section">
|
|
<h4>Rights</h4>
|
|
<div class="admin-right-list">
|
|
${rights.map((right) => {
|
|
const checked = userHasAdminRight(selectedUser.id, right.id);
|
|
const isSelfAdminRight = appFlag('isAdmin')
|
|
&& String(selectedUser.id) === String(getCurrentUserId())
|
|
&& String(right.name) === 'Admin';
|
|
|
|
return `
|
|
<label class="admin-right-item">
|
|
<input
|
|
type="checkbox"
|
|
data-admin-user-right
|
|
data-user-id="${escapeHtml(selectedUser.id)}"
|
|
data-right-id="${escapeHtml(right.id)}"
|
|
${checked ? 'checked' : ''}
|
|
${isSelfAdminRight ? 'disabled' : ''}
|
|
>
|
|
<span>${escapeHtml(right.name)}</span>
|
|
</label>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-user-section">
|
|
<h4>Project Access</h4>
|
|
<form class="admin-project-access-form" id="adminProjectAccessForm">
|
|
<select class="form-select form-select-sm" name="project_id" ${addableProjects.length ? '' : 'disabled'}>
|
|
${addableProjects.map((project) => `
|
|
<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)} (${escapeHtml(project.id)})</option>
|
|
`).join('')}
|
|
</select>
|
|
<button class="btn btn-sm btn-primary" type="submit" ${addableProjects.length ? '' : 'disabled'}>
|
|
<i class="fa-solid fa-plus me-1"></i>
|
|
Add Access
|
|
</button>
|
|
</form>
|
|
|
|
<div class="admin-project-access-list">
|
|
${explicitAccess.map((access) => {
|
|
const project = getAdminProject(access.project_id);
|
|
|
|
return `
|
|
<div class="admin-project-access-item">
|
|
<span>${escapeHtml(project?.name ?? access.project_id)} <small>${escapeHtml(access.project_id)}</small></span>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-project-access-delete="${escapeHtml(access.id)}" title="Remove access">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('') || '<div class="text-secondary">No explicit project access yet.</div>'}
|
|
</div>
|
|
|
|
${ownerProjects.length ? `
|
|
<div class="admin-project-owner-list">
|
|
<strong>Owner access</strong>
|
|
${ownerProjects.map((project) => `
|
|
<span>${escapeHtml(project.name)} <small>${escapeHtml(project.id)}</small></span>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|